4、单目相机标定实战:准备标定图片、提取角点、亚像素精确化、计算内参矩阵和畸变系数、重投影误差评估
好,咱们今天来点真格的。前面讲了那么多理论,什么针孔模型、畸变模型,说白了都是为了这一刻——把相机标定出来。我刚开始学标定时,总觉得这玩意儿很玄乎,后来亲手跑了一遍流程才发现,其实核心就五个步骤。嗯,咱们一个一个来过。
4.1 准备标定图片——别小看这一步
我个人习惯,标定图片至少拍15到20张。为什么是这个数?太少的话,解出来的参数不稳定;太多呢,边际效益递减,还浪费时间。你想想看,我们是要解内参矩阵的5个参数(fx, fy, cx, cy)加上畸变系数,样本量不够,拟合出来的结果你敢用吗?
拍的时候有几个要点,我踩过坑,得跟你说清楚:
- 棋盘格要占画面1/3到1/2——太小了角点检测不准,太大了容易出画面边缘畸变严重的区域
- 角度要丰富——左右倾斜、上下俯仰、前后移动,各种姿势都来几张。我在项目中遇到过只拍了正面图片的情况,结果标定出来的畸变系数完全不对,重投影误差大得离谱
- 光照要均匀——别让棋盘格上有反光或者阴影,不然角点检测会失败
- 棋盘格要平整——贴在硬纸板上,别用软布,否则角点坐标本身就有误差
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像素,哪个先到就停
- 输入必须是灰度图——彩色图不行,这个我吃过亏
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),径向畸变和切向畸变都包含了
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);
}
4.6 完整流程总结
好了,咱们把整个流程串起来,你心里有个谱:
- 准备图片:15-20张,角度丰富,光照均匀
- 提取角点:
findChessboardCorners,注意patternSize是内角点数量 - 亚像素精确化:
cornerSubPix,窗口11×11,精度0.001 - 计算内参和畸变:
calibrateCamera,世界坐标用实际尺寸 - 评估误差:重投影误差小于0.5像素为佳
说实话,标定这件事,理论不难,但实操中坑不少。我刚开始做的时候,光图片就拍了三遍才合格。不过一旦你掌握了这个流程,后面做三维重建、AR、机器人视觉,都会顺手很多。
下一章咱们聊聊双目相机标定,那个比单目稍微复杂一点,但核心思路是一样的。嗯,先消化这些吧。