简介:面向使用OpenCV进行相机标定的C++开发者,这份资源提供一套完整的棋盘格自动检测程序,将常见Matlab相机标定算法改写为C++实现,可自动查找图像中最大棋盘格并识别所有独立棋盘格,配合OpenCV即可完成相机内参标定。资源压缩包约30MB,共1727个文件,核心源码包括618个cpp、427个h、130个hpp等源文件,另有bmp棋盘格测试图、txt/cmake构建与说明文件,以及Eigen矩阵库等第三方依赖支持,工程结构较为完整。资源内还带有多个示例图像和OpenCV相关配置,可帮助读者理解棋盘格检测、角点提取与内参求解的完整流程。目前已有117人学习下载。通过源码和示例,可以掌握算法移植思路、自动化检测多棋盘格的实现细节,并快速嵌入自己的视觉标定项目中。
1. C++ 棋盘格自动检测程序:从 MATLAB 标定流程拆出的工程问题
拿到这个标题,我想到的是一句常说的话:MATLAB 里detectCheckerboardPoints一行调出几十个角点,但换成 C++ 落地,事情远没有看起来那么简单。所谓 C++ 棋盘格自动检测程序,本质是把 MATLAB Camera Calibrator 里「棋盘格角点提取 + 内参估计」这一段流程,用 OpenCV 的findChessboardCorners、cornerSubPix、calibrateCamera重写一遍,最终输出相机内参矩阵、畸变系数和重投影误差。它解决的是产线视觉、双目深度估计、AGV 定位里「要脱离 MATLAB 运行时、想嵌进实时采集线程」的场景,适合已经用 MATLAB 标定过相机、现在要写 C++ 服务或嵌入式程序的工程师。整个流程里,角点检测只算四成工作量,剩下的坑集中在亚像素细化、角点排序和物理坐标映射。
2. 棋盘格角点候选提取:findChessboardCorners 的预处理与参数选择
2.1 检测原理:从二值化到棋盘拓扑
OpenCV 的findChessboardCorners并不是直接在灰度图上找角点,而是一条多阶段几何管线。它先对图像做自适应二值化和归一化,再用形态学闭运算连接棋盘格内的黑色方块,之后用轮廓检测把每个四边形筛出来,最后根据四边形之间的邻接关系构建整个棋盘拓扑。这一步几乎就是 MATLABdetectCheckerboardPoints的粗检测阶段,只不过 MATLAB 在光照不均和低对比度图像上做了更强的内部补偿,所以显得更「智能」。
findChessboardCorners的行为由 flags 参数直接控制,四个常用项作用完全不同:
| flags 取值 | 作用 | 适用场景 |
|---|---|---|
CALIB_CB_ADAPTIVE_THRESH | 用自适应阈值替代全局阈值,处理光照渐变 | 产线环境,推荐默认开启 |
CALIB_CB_NORMALIZE_IMAGE | 检测前对图像做直方图归一化 | 过暗或过亮的标定板图像 |
CALIB_CB_FILTER_QUADS | 用面积、边长等条件过滤掉不合格四边形 | 背景杂乱的实拍图,能显著降低误检 |
CALIB_CB_FAST_CHECK | 先快速判断图像里大概是否存在棋盘格 | 视频流预检,节省算力 |
2.2 提高检出率的一段 C++ 预处理代码
我一般在检测前加一层 CLAHE 局部对比度增强,再用多尺度兜底,这样能覆盖大多数实拍场景里光照不均匀的问题。
cv::Mat gray; cv::cvtColor(colorImage, gray, cv::COLOR_BGR2GRAY); cv::Ptr<cv::CLAHE> clahe = cv::createCLAHE(2.0, cv::Size(16, 16)); cv::Mat enhanced; clahe->apply(gray, enhanced); cv::Size board(9, 6); // 内角点数量,不是棋盘格数量 std::vector<cv::Point2f> corners; int flags = cv::CALIB_CB_ADAPTIVE_THRESH | cv::CALIB_CB_NORMALIZE_IMAGE | cv::CALIB_CB_FILTER_QUADS; bool ok = cv::findChessboardCorners(enhanced, board, corners, flags); if (!ok) { // 降分辨率再检测一次,再把坐标映射回原图 cv::Mat small; cv::resize(enhanced, small, cv::Size(), 0.5, 0.5, cv::INTER_LINEAR); std::vector<cv::Point2f> smallCorners; if (cv::findChessboardCorners(small, board, smallCorners, flags)) { for (auto &pt : smallCorners) { pt.x *= 2.0f; pt.y *= 2.0f; } corners = std::move(smallCorners); ok = true; } }这段代码的关键在于board(9, 6)是内角点数,也就是说物理棋盘是 10x7 个方格,这是初学者最容易写错的第一处。CLAHE的2.0是对比度限制,值越大增强越强,但过大会把棋盘格边缘拉出伪轮廓,实拍里一般取 1.5 到 3.0 之间。降分辨率检测是因为大分辨率图像里四边形轮廓容易断裂,缩小一倍后棋盘拓扑更容易闭合,代价是角点定位精度的确会略差,所以这一步只作为兜底,不能替代正常尺寸下的检测。
2.3 检测失败的降级策略
如果上述流程在某个图像上还是失败,我一般会检查三件事。第一,棋盘格是否占满画面三分之一以上,太小的话四边形互相粘连,形态学闭运算会把格子连成一大片。第二,图像是否过曝,白色方块高光溢出后,角点周围梯度信息丢失,CALIB_CB_NORMALIZE_IMAGE只能做线性拉伸,无法恢复过曝区域。第三,把 flags 里的CALIB_CB_FILTER_QUADS暂时去掉再跑一次,确认是不是四边形过滤规则误杀了真实棋盘块。
提示:在 VSCode 配好 C/C++ 环境后,建议把这段检测逻辑单独编译成一个命令行工具,输入一张图片、输出检测结果图,调试效率比直接跑完整标定程序高得多。
3. 亚像素角点细化:cornerSubPix 的误差模型与 4 个必调参数
3.1 为什么要做亚像素细化
findChessboardCorners返回的角点坐标是像素整数精度,对相机标定这种需要重投影误差低于 0.1 像素的场景来说远远不够。棋盘格角点在局部图像里呈现为两个方向的灰度梯度交界,理想角点位置周围的梯度方向都垂直于「像素点到角点」的连线,据此可以构造一个最小二乘问题:对候选点邻域内的每个像素计算梯度方向,让所有像素的梯度方向与连线方向的点积之和最小,解出的偏移量叠加到原坐标上,就是亚像素坐标。
cornerSubPix优化的正是这个误差函数,它迭代地在邻域内收集梯度信息,求解一个 2x2 线性方程组得到偏移量。MATLAB 的detectCheckerboardPoints在亚像素基础上还会对整幅棋盘的所有角点做一次全局优化,让角点位置同时满足棋盘网格的几何约束,这是 C++ 里cornerSubPix单点局部优化不具备的。所以 C++ 端要多做一步:用理想网格投影来校验每个角点,剔除偏离过大的点。
3.2 4 个必调参数及建议值
cv::cornerSubPix(gray, corners, cv::Size(11, 11), cv::Size(-1, -1), cv::TermCriteria(cv::TermCriteria::EPS + cv::TermCriteria::MAX_ITER, 30, 0.001));四个关键参数如下:
| 参数 | 含义 | 建议值 |
|---|---|---|
winSize | 搜索窗口半宽,实际窗口是 (2n+1)x(2n+1) | Size(11, 11)对应 23x23 窗口 |
zeroZone | 中心零区半宽,设为 0 表示不设零区 | Size(-1, -1)表示无零区 |
MAX_ITER | 迭代次数上限 | 30 左右足够收敛 |
EPS | 亚像素偏移量的终止精度 | 0.001 像素,再小意义不大 |
winSize是效果最敏感的参数。窗口太小,梯度信息不足,角点会被噪声拉偏;窗口太大,会混入旁边棋盘格边缘的梯度,角点位置会被相邻方块边缘干扰。对于 9x6 内角点、单个方格 25mm 的常见标定板,如果棋盘在画面里占 1/3 以上,Size(11, 11)一般够用;如果棋盘在画面里很小,窗口要跟着缩小到Size(7, 7),否则窗口跨过了好几个棋盘格周期。
3.3 剔除不合格角点:对应双目标定里的质量门禁
cornerSubPix之后,我还会加一道基于单应性残差的校验,这一步对应原 MATLAB 流程中没有显式暴露、但内部在估计外参时一定会做的角点质量筛选。
std::vector<cv::Point2f> refined; for (size_t i = 0; i < corners.size(); ++i) { cv::Point2f ideal = projectToIdealGrid(corners[i], gridStep); if (cv::norm(corners[i] - ideal) < 1.5) { refined.push_back(corners[i]); } }projectToIdealGrid的做法是:取检测到的所有角点拟合一个棋盘平面单应,把理想棋盘网格点投到图像上,再计算每个检测点与投影点的距离。残差大于 1.5 像素的点直接剔除。双目相机标定里经常提到「剔除不合格角点」,说的就是这个环节——左右目各自检测后,用对极几何或单应残差把误匹配点筛掉,否则双目标定的立体外参会整体漂移。阈值 1.5 像素不要放宽,放宽后内参标定的重投影误差会被「包含错误点」的平均掩盖掉。
4. 棋盘格角点排序与物理坐标映射:标定前最容易被忽略的一步
4.1 排序错误导致标定发散的原因
calibrateCamera的输入是成对的 imagePoints 和 objectPoints,它要求第 i 个图像点必须对应第 i 个世界坐标点。棋盘格是高度对称的结构,如果角点顺序错乱,相当于世界坐标系下 (0,0) 挪到了图像里的另一个位置,单应性约束被彻底打乱,内参矩阵初值就会发散。MATLAB 的detectCheckerboardPoints对输出顺序有固定的约定规则,但 OpenCV 不同版本在某些边界情况下返回的角点排序并不一致,尤其当棋盘旋转、倒置时。
一位真正做过标定落地的工程师不会依赖findChessboardCorners的隐含顺序,而是自己按棋盘拓扑重建行列索引。物理世界里棋盘是一个 9x6 的网格,每个角点都对应唯一的 (row, col),把图像坐标映射到这个网格上,再按约定顺序输出。
4.2 用棋盘空间拓扑重建索引
我的做法是先找到左上角的锚点,再根据两个边缘方向把每个角点投影到网格坐标。
// corners 是 findChessboardCorners 的原始输出 int rows = 6, cols = 9; cv::Point2f origin = corners[0]; for (auto &p : corners) { if (p.x + p.y < origin.x + origin.y) origin = p; } std::vector<cv::Point2f> reordered(rows * cols); cv::Point2f axisX = corners[1] - origin; // 最近邻之一 double lenX = cv::norm(axisX); axisX /= lenX; // 找与 origin 距离次小的点作为 Y 方向 double minDist = 1e9; cv::Point2f axisY; for (auto &p : corners) { double d = cv::norm(p - origin); if (d > lenX * 0.5 && d < minDist) { minDist = d; axisY = p - origin; } } axisY /= minDist; for (auto &p : corners) { cv::Point2f v = p - origin; int col = cvRound(v.dot(axisX) / lenX); int row = cvRound(v.dot(axisY) / minDist); if (row >= 0 && row < rows && col >= 0 && col < cols) reordered[row * cols + col] = p; }这段代码用到两个假设:第一个,origin取图像坐标系里 x+y 最小的点,在棋盘无大倾斜时约等于左上角;第二个,X 方向取离origin最近的点,Y 方向取次近点。dot投影到两个轴后,用距离归一化得到行列号。注意这里lenX是像素距离,如果棋盘在画面里有透视畸变,边缘处的点会偏离理想网格点,但因为cvRound只需要落在正确网格范围内,透视造成的偏差通常不足以跳格。
4.3 物理世界坐标的生成规则
世界坐标采用「X 沿棋盘行方向,Y 沿列方向,Z 垂直板面」的右手系,原点放在第一个内角点上,单位是毫米。
std::vector<cv::Point3f> generateBoardPoints(int rows, int cols, float squareSize) { std::vector<cv::Point3f> pts; for (int r = 0; r < rows; ++r) for (int c = 0; c < cols; ++c) pts.emplace_back(c * squareSize, r * squareSize, 0.0f); return pts; }squareSize必须与实际标定板的格子边长一致。这里有一个非常实际的工程陷阱:如果用 25mm 的板子但代码里写 25.0f,得到的内参 fx、fy 单位是像素,这是正确的;如果用英寸但写成 25.0f,内参数值不会变,但所有平移向量 t 的单位会错。棋盘格标定得到的焦距是以像素为单位的,这个值本身不随板子尺寸变化,但外参平移量会。
5. 相机标定算法实现:从单应约束到 calibrateCamera 的 MATLAB 等价映射
5.1 张正友标定法在 C++ 里的数学骨架
MATLABestimateCameraParameters底层是张正友标定法,C++ 端calibrateCamera同样如此。核心思路是:棋盘平面上的世界点 M,经单应矩阵 H 映射到图像点 m,每一张棋盘图都给出一组约束。
设H = [h1 h2 h3],旋转矩阵列向量r1, r2与平移向量t满足[h1 h2 h3] = λK[r1 r2 t]。旋转矩阵列向量单位正交,因此有r1ᵀr2 = 0且|r1| = |r2|,代入后得到两个关于内参矩阵 K 的二次约束:
h1ᵀ K⁻ᵀ K⁻¹ h2 = 0
h1ᵀ K⁻ᵀ K⁻¹ h1 = h2ᵀ K⁻ᵀ K⁻¹ h2
每张图提供两个方程,B = K⁻ᵀK⁻¹是对称矩阵,有 6 个未知量,所以理论上 3 张不同姿态的棋盘图就能解出内参闭式解。之后再用最大似然估计对全部内参、外参和畸变系数做非线性优化,对应 MATLAB 工具箱里的Optimize选项。整个过程在 C++ 里不需要自己实现,calibrateCamera内部完成闭式解初始化加 LM 迭代。
5.2 从检测到内参输出的完整 C++ 最小实现
std::vector<std::vector<cv::Point3f>> objectPoints; std::vector<std::vector<cv::Point2f>> imagePoints; for (auto &imgPath : imagePaths) { cv::Mat img = cv::imread(imgPath); std::vector<cv::Point2f> corners; if (!findBoardCorners(img, corners)) // 封装第2、3章的检测流程 continue; cv::cornerSubPix(gray, corners, cv::Size(11, 11), cv::Size(-1, -1), cv::TermCriteria(cv::TermCriteria::EPS + cv::TermCriteria::MAX_ITER, 30, 0.001)); objectPoints.push_back(generateBoardPoints(6, 9, 25.0f)); imagePoints.push_back(corners); } cv::Mat K, distCoeffs; std::vector<cv::Mat> rvecs, tvecs; double rms = cv::calibrateCamera(objectPoints, imagePoints, imgSize, K, distCoeffs, rvecs, tvecs, cv::CALIB_RATIONAL_MODEL, cv::TermCriteria(cv::TermCriteria::EPS + cv::TermCriteria::MAX_ITER, 60, 1e-10));CALIB_RATIONAL_MODEL会把畸变模型从默认的 5 参数扩到 8 参数,对应 k1, k2, p1, p2, k3, k4, k5, k6,与 MATLAB 里选择 3 阶径向畸变模型更接近。但如果标定图数量不足,高次畸变项会过拟合,我一般先跑默认模型,重投影误差大于 0.15 像素再打开CALIB_RATIONAL_MODEL。标定图数量建议不少于 15 张,且覆盖画面中心、四周、倾斜和旋转姿态,这比单纯追求图片数量更重要。
5.3 与 MATLAB 工具箱的参数对应关系
| MATLAB 功能 | C++/OpenCV 对应 | 说明 |
|---|---|---|
detectCheckerboardPoints | findChessboardCorners+cornerSubPix | 粗检测加亚像素细化 |
estimateCameraParameters | calibrateCamera | 闭式解初始化加 LM 优化 |
NumRadialDistCoeffs | CALIB_RATIONAL_MODEL及 k1-k6 | 径向畸变阶数对应 |
EstimateTangentialDistortion | 默认 p1, p2 | 切向畸变默认打开 |
Optimize选项 | CALIB_FIX_*系列 flags | 固定主点或固定某阶畸变 |
CALIB_FIX_K3、CALIB_FIX_PRINCIPAL_POINT等 flags 对应 MATLAB 里固定某几个参数不参与优化的选项。需要特别区分的是:市面上常说的「相机 9 点标定算法」指的是像素坐标到机械坐标的手眼标定,输入是点阵标定板上的圆心或角点,输出是变换矩阵,和这里棋盘格内参标定是两套算法,不要在参数设计上混为一谈。
6. 用重投影误差和子集验证收紧标定结果,兼谈多尺度检测兜底
6.1 三个验证维度
拿到 K 和畸变系数后,第一步看rms重投影误差,小于 0.15 像素是基本及格线。但这个值只说明平均残差小,无法暴露某个角点的系统偏差,所以我会再做两步验证。第一,把 20 张标定图随机分成 A、B 两组,分别跑calibrateCamera,比较两组得到的 fx 和 fy 差多少,偏差超过 0.5% 说明姿态覆盖不够,需要补图。第二,用标定结果去矫正一张未参与标定的棋盘图,查看棋盘边缘直线是否拉直,这一步能直观发现畸变模型阶数是否够用。另外,双目标定场景会复用同一套棋盘格角点,生成双目立体标定所需的左右目对应点对,这就是「双目相机标定剔除不合格角点」的价值所在——先剔除再标定,立体外参才不会把残差平均到错误匹配上。
6.2 多尺度检测兜底
实际采集标定图时,总有几张因为运动模糊或极端光照导致检测失败,这种图直接跳过可能让整组姿态覆盖出现空洞。兜底策略是把第 2 章里的降分辨率检测抽成通用 wrapper:
cv::Mat scales[] = {gray, blurred, halfRes}; for (auto &s : scales) { if (cv::findChessboardCorners(s, board, corners, flags)) { if (s.cols != gray.cols) { for (auto &pt : corners) { pt.x *= (float)gray.cols / s.cols; pt.y *= (float)gray.rows / s.rows; } } break; } }三层尺度里,blurred用轻度高斯模糊去掉传感器噪声导致的伪四边形,halfRes处理高分辨率图像里轮廓断裂的问题。注意放大回原图坐标时,如果长宽缩放比不一致,要分别用对应轴的比值。把这层多尺度检测和 CLAHE 一起塞进采集线程的预检里,误检率通常还能再降半成。
本文还有配套的精品资源,点击获取