4、单目相机标定实战:准备标定图片、提取角点、亚像素精确化、计算内参矩阵和畸变系数、重投影误差评估

好,咱们今天来点真格的。前面讲了那么多理论,什么针孔模型、畸变模型,说白了都是为了这一刻——把相机标定出来。我刚开始学标定时,总觉得这玩意儿很玄乎,后来亲手跑了一遍流程才发现,其实核心就五个步骤。嗯,咱们一个一个来过。

4.1 准备标定图片——别小看这一步

我个人习惯,标定图片至少拍15到20张。为什么是这个数?太少的话,解出来的参数不稳定;太多呢,边际效益递减,还浪费时间。你想想看,我们是要解内参矩阵的5个参数(fx, fy, cx, cy)加上畸变系数,样本量不够,拟合出来的结果你敢用吗?

拍的时候有几个要点,我踩过坑,得跟你说清楚:

  • 棋盘格要占画面1/3到1/2——太小了角点检测不准,太大了容易出画面边缘畸变严重的区域
  • 角度要丰富——左右倾斜、上下俯仰、前后移动,各种姿势都来几张。我在项目中遇到过只拍了正面图片的情况,结果标定出来的畸变系数完全不对,重投影误差大得离谱
  • 光照要均匀——别让棋盘格上有反光或者阴影,不然角点检测会失败
  • 棋盘格要平整——贴在硬纸板上,别用软布,否则角点坐标本身就有误差
我的小技巧:打印棋盘格时,用A4纸打印,贴在亚克力板或者硬纸板上。我一般用10×7的内角点棋盘格(也就是内部有9×6个格子),每个格子边长25mm或30mm。这个尺寸在大多数场景下都够用。

4.2 提取角点——findChessboardCorners

图片准备好了,接下来就是提取角点。OpenCV里直接用cv::findChessboardCorners()就行。这个函数会返回棋盘格上所有内角点的像素坐标。

代码其实很简单:

cv::Mat img = cv::imread("calib_01.jpg");
cv::Mat gray;
cv::cvtColor(img, gray, cv::COLOR_BGR2GRAY);

cv::Size patternSize(9, 6);  // 内角点数量
std::vector<cv::Point2f> corners;
bool found = cv::findChessboardCorners(gray, patternSize, corners);

if (found) {
    cv::drawChessboardCorners(img, patternSize, corners, found);
    cv::imshow("Corners", img);
    cv::waitKey(0);
}

这里有个坑——patternSize是内角点的数量,不是格子的数量。比如棋盘格是10×7个格子,那内角点就是9×6。我曾经搞反过一次,找了半天找不到角点,还以为代码写错了。

另外,findChessboardCorners有时候会失败,特别是图片模糊或者光照不好的时候。我建议你加个循环,把失败的图片单独列出来,重新拍。别硬凑,不然标定结果会带偏。

4.3 亚像素精确化——让精度再上一个台阶

提取出来的角点坐标,精度其实只有像素级别。说白了,就是整数坐标。但实际角点可能落在像素之间,比如(123.4, 567.8)。这时候就需要亚像素精确化。

OpenCV提供了cv::cornerSubPix()函数,它基于梯度法,在初始角点附近搜索更精确的位置。用法如下:

cv::TermCriteria criteria(
    cv::TermCriteria::EPS + cv::TermCriteria::MAX_ITER, 
    30,   // 最大迭代次数
    0.001 // 精度
);
cv::cornerSubPix(gray, corners, cv::Size(11, 11), cv::Size(-1, -1), criteria);

嗯,这里要注意几个参数:

  • 窗口大小:我一般用11×11,太小了信息不够,太大了容易受周围干扰
  • 终止条件:迭代30次或者精度达到0.001像素,哪个先到就停
  • 输入必须是灰度图——彩色图不行,这个我吃过亏
为什么要做亚像素精确化? 因为后续计算内参矩阵时,角点坐标的微小误差会被放大。你想想看,如果角点坐标偏差0.5个像素,重投影误差可能就差了0.3个像素。对于高精度应用(比如三维重建、机器人抓取),这个误差是不能接受的。

4.4 计算内参矩阵和畸变系数——核心步骤

角点提取完了,接下来就是调用cv::calibrateCamera()。这个函数会一次性算出内参矩阵、畸变系数、旋转向量和平移向量。

代码示例:

// 准备世界坐标系中的角点坐标
std::vector<std::vector<cv::Point3f>> objectPoints;
std::vector<std::vector<cv::Point2f>> imagePoints;
cv::Size imageSize = gray.size();

// 假设棋盘格每个格子25mm
float squareSize = 25.0f;
for (int i = 0; i < patternSize.height; i++) {
    for (int j = 0; j < patternSize.width; j++) {
        objectPoints_per_img.push_back(
            cv::Point3f(j * squareSize, i * squareSize, 0)
        );
    }
}

// 标定
cv::Mat cameraMatrix, distCoeffs;
std::vector<cv::Mat> rvecs, tvecs;
double rms = cv::calibrateCamera(
    objectPoints, imagePoints, imageSize,
    cameraMatrix, distCoeffs, rvecs, tvecs
);

这里有几个关键点:

  • 世界坐标系:我们把棋盘格平面当作Z=0,角点坐标就是(x, y, 0)。x和y的单位是毫米,跟实际尺寸一致
  • 内参矩阵:输出是一个3×3的矩阵,包含fx, fy, cx, cy
  • 畸变系数:默认是5个参数(k1, k2, p1, p2, k3),径向畸变和切向畸变都包含了
注意:如果标定结果中fx和fy相差很大(比如超过10%),或者cx, cy偏离图像中心太远,说明标定可能有问题。我遇到过因为棋盘格图片数量不够,导致fx和fy差了20%的情况。后来加了5张不同角度的图片,结果就正常了。

4.5 重投影误差评估——标定好不好,看这个数

标定完了,怎么知道结果准不准?最直观的指标就是重投影误差

简单说,就是把世界坐标系中的角点,用标定出来的内参和畸变系数投影到图像上,算一下投影点和实际检测到的角点之间的距离。这个距离的平均值就是重投影误差。

OpenCV的calibrateCamera()会直接返回这个值,存在rms变量里。一般来说:

  • 小于0.5像素:非常好,可以用于高精度应用
  • 0.5到1.0像素:还行,普通应用够用
  • 大于1.0像素:需要检查一下,可能是图片质量不好或者角点检测有问题

如果你想手动算一下,可以这样:

double computeReprojectionError(
    const std::vector<std::vector<cv::Point3f>>& objectPoints,
    const std::vector<std::vector<cv::Point2f>>& imagePoints,
    const cv::Mat& cameraMatrix,
    const cv::Mat& distCoeffs,
    const std::vector<cv::Mat>& rvecs,
    const std::vector<cv::Mat>& tvecs
) {
    double totalError = 0;
    int totalPoints = 0;
    
    for (size_t i = 0; i < objectPoints.size(); i++) {
        std::vector<cv::Point2f> projectedPoints;
        cv::projectPoints(
            objectPoints[i], rvecs[i], tvecs[i],
            cameraMatrix, distCoeffs, projectedPoints
        );
        
        double error = cv::norm(
            imagePoints[i], projectedPoints, cv::NORM_L2
        );
        totalError += error * error;
        totalPoints += objectPoints[i].size();
    }
    
    return std::sqrt(totalError / totalPoints);
}
我的经验:如果重投影误差大于1.0像素,先别急着调参数。我一般会做两件事:第一,检查有没有模糊或者反光的图片,直接删掉;第二,检查棋盘格是不是贴平了。有一次我发现误差大,结果是因为棋盘格贴在一个弯曲的纸板上,角点坐标本身就有系统误差。

4.6 完整流程总结

好了,咱们把整个流程串起来,你心里有个谱:

  1. 准备图片:15-20张,角度丰富,光照均匀
  2. 提取角点findChessboardCorners,注意patternSize是内角点数量
  3. 亚像素精确化cornerSubPix,窗口11×11,精度0.001
  4. 计算内参和畸变calibrateCamera,世界坐标用实际尺寸
  5. 评估误差:重投影误差小于0.5像素为佳

说实话,标定这件事,理论不难,但实操中坑不少。我刚开始做的时候,光图片就拍了三遍才合格。不过一旦你掌握了这个流程,后面做三维重建、AR、机器人视觉,都会顺手很多。

下一章咱们聊聊双目相机标定,那个比单目稍微复杂一点,但核心思路是一样的。嗯,先消化这些吧。