
简介这是一套基于C实现的棋盘格自动检测与相机标定工具面向计算机视觉开发者和机器人/自动驾驶方向的研究人员可在图像中自动查找最大棋盘格并检测所有独立棋盘格配合OpenCV完成相机内参标定。资源包共包含1727个文件大小约30.22MB其中以C源码618个cpp和头文件427个h及130个hpp为主便于工程集成与二次开发此外还包含涉及OpenCV接口的代码、Eigen数值库头文件、示例图像bmp/png、CMake构建脚本、CSV标定数据以及少量Python辅助脚本结构清晰。目前已有117人学习下载。通过源码与工程配置读者可以快速掌握棋盘格角点提取、最大棋盘格筛选、多棋盘格独立识别等核心思路并直接复用标定流程与参数配置减少从MATLAB算法移植到C环境的重复工作。1. 从 Matlab 标定工具箱到 C 部署棋盘格自动检测要解决什么做过相机内参标定的人都有体会Matlab 标定工具箱交互式点选角点非常顺手但一进到自动化产线或者嵌入式环境就抓瞎——你不能让现场工程师打开 App 手动框选棋盘格。这个项目把 Matlab 的棋盘格自动查找逻辑用 C 重新实现了一遍配合 OpenCV 完成从图像输入到内参矩阵输出的整条链路。它解决的核心问题很具体图像里不止一个棋盘格时程序要自动找出面积最大的那块、同时把每块独立棋盘格按行列为序整理输出。适合正在做视觉标定工具封装、或者想把 Matlab 原型迁到 C 服务里的工程师也适合被 OpenCV 原生findChessboardCorners在多棋盘格场景下搞崩的人拿来当参考。2. 棋盘格角点检测与亚像素定位算法选型与 C 数据结构设计2.1 为什么不能只靠 OpenCV 原生函数OpenCV 的findChessboardCorners本身能返回一组角点但它有几个致命局限单张图里存在多块独立棋盘格时它只会返回搜到的第一块或者直接失败它对图像边缘截断的棋盘格特别敏感只要最外圈角点被裁掉一点整个检测就崩还有一个隐含问题——它不保证角点按行优先、列优先的规整顺序输出而相机标定恰恰要求物方点和像方点严格一一对应。所以这个项目做了比 OpenCV 更上层的事在调用原生角点检测之前做候选区域筛选在拿到原始角点之后做连通性分析和排序。常见做法是把整张图做一次棋盘格角点响应图chessboard corner response map但工程上更省事且稳定的方案是多尺度滑窗 自适应阈值。该项目正文里出现的bench_unrolling、AutoDiff这些文件说明作者用了 Eigen 做数值计算整体代码风格偏底层模板化这也提醒我们如果要做多棋盘格筛选数据结构一开始就不能只存一个 vector。2.1.1 角点响应与多尺寸搜索策略我一般在拿到输入图像后先做一次灰度化不做高斯模糊或只做极轻的 3x3 模糊因为棋盘格角点的梯度方向信息在模糊后会被削掉。接着在多个尺度上调用findChessboardCorners尺度的意思是把图像按 0.8、1.0、1.2 三档缩放后再检测避免棋盘格实际尺寸和 pattern 参数不匹配导致的内角点漏检。每一档检测成功后立刻用cornerSubPix做亚像素细化而不是等所有尺度都跑完再统一细化这样能减少后续连通域分析时的坐标抖动。// 多尺度棋盘格检测先从大尺度往下搜优先保证完整棋盘格优先检出 std::vectorcv::Point2f corners; cv::Size pattern(9, 6); // 内角点数量不是格子数量 std::vectordouble scales {1.2, 1.0, 0.8}; for (double s : scales) { cv::Mat resized; cv::resize(gray, resized, cv::Size(), s, s, cv::INTER_LINEAR); bool found cv::findChessboardCorners( resized, pattern, corners, cv::CALIB_CB_ADAPTIVE_THRESH cv::CALIB_CB_NORMALIZE_IMAGE ); if (!found) continue; // 把坐标缩放回原图尺寸 for (auto p : corners) { p.x / s; p.y / s; } // 亚像素细化直接作用在原图坐标上 cv::cornerSubPix(gray, corners, cv::Size(11, 11), cv::Size(-1, -1), cv::TermCriteria(cv::TermCriteria::EPS cv::TermCriteria::COUNT, 30, 0.01)); break; }这段代码的逻辑缩放系数从大到小优先使用放大 1.2 倍的图像做检测这样小棋盘格也能被放大到足够像素宽度。CALIB_CB_ADAPTIVE_THRESH和CALIB_CB_NORMALIZE_IMAGE两个 flag 对光照不均的图片友好前者用局部自适应阈值替代全局阈值后者先归一化再二值化。cornerSubPix的窗口(11, 11)是经验值窗口太大易受邻近角点干扰太小则亚像素不收敛zeroZone设为(-1, -1)表示对搜索窗口内所有像素加权不要设成正值否则会人为挖掉角点中心区域。2.1.2 数据结构设计从 Point2f 数组到棋盘格候选对象只存角点坐标做不了多棋盘格分析因为候选角点之间缺少拓扑关系。这个项目里应该定义了一个类似ChessboardCandidate的结构体我一般会这样写struct ChessboardCandidate { std::vectorcv::Point2f corners; // 亚像素角点行优先排列 int rows 0; // 内角点行数 int cols 0; // 内角点列数 double area 0.0; // 棋盘格覆盖面积 cv::Mat homography; // 角点到理想网格的单应矩阵后续排序用 };area不要直接用rows * cols因为透视畸变大的棋盘格在图像里的实际覆盖面积远比行列乘积小后续区分哪块是“最大棋盘格”时要用真实像素面积。这个结构体在 OpenCV 的findChessboardCorners成功返回后填充一次如果检测失败就不产生候选对象流程继续滑动到下一个感兴趣区域。2.2 亚像素细化的边界条件与参数陷阱cornerSubPix的第三个参数zeroZone很多新手会忽略默认(-1, -1)表示不做零区域限制这是正确的。如果你设成(5, 5)角点中心周围 5x5 区域会被排除在加权计算之外导致最终角点坐标系统性偏移重投影误差直接超过 0.5 像素。另一个容易踩的是TermCriteria的迭代次数设太小30 次对普通棋盘格够用但如果角点附近有遮挡物或者反光30 次可能没收敛到亚像素极限可以放宽到 50 次。代价是每张图多耗几毫秒对标定这种离线任务完全可接受。3. 多棋盘格场景下的最大棋盘格查找连通域分析与坐标变换实现3.1 从散乱角点到棋盘格拓扑的构建思路当图像里有多块棋盘格findChessboardCorners每次都只会给出其中一块的角点集合。要找出所有独立棋盘格就得在图像里做一遍完整的角点候选扫描比如用FastFeatureDetector检测角点候选再做模式匹配或者用滑动窗口在每个局部区域单独跑一遍棋盘格检测。这个项目的实际做法是把图像切分成重叠网格每个窗口独立检测然后把坐标位于同一窗口重叠区、且行列数能拼合的候选合并成同一块棋盘格。这里的关键是把二维图像坐标转换到棋盘格的行列坐标转换需要用单应矩阵把像素坐标映射到以棋盘格格角为原点的规整网格上。我自己的实现里会先把单应矩阵求出来再做核验而不是直接靠距离聚类。单应矩阵H把像素坐标映射到网格坐标假设棋盘格内角点是(cols, rows)排列理想网格坐标就是(i * square_size, j * square_size, 0)其中i取值[0, cols-1]j取值[0, rows-1]square_size可以先设成 1.0 做归一化。求单应用cv::findHomography然后投影所有角点到网格坐标系看投影点和理想网格点的距离误差是否小于阈值。这一步同时校验了角点是否真的属于同一个棋盘格也能剔掉单应拟合错误的假阳性候选。3.1.1 重叠窗口扫描与聚类合并滑动窗口的大小推荐用1.5 * pattern_size的像素宽度步长取窗口宽度的三分之一这样横向纵向都有足够的重叠区。重叠区域检测到两套角点分别属于相邻窗口时用 IoUIntersection over Union判断是不是同一块棋盘格。如果是就把后发现的窗口角点坐标统一到第一个窗口的坐标系里重新做一次单应拟合直到所有窗口的角点都归属到某一块。// 重叠窗口检测角点后按角点覆盖面积并集求 IoU float bbox_iou(const cv::Rect a, const cv::Rect b) { int inter (a b).area(); int uni a.area() b.area() - inter; return uni 0 ? 0.0f : static_castfloat(inter) / uni; } void mergeCandidates(std::vectorChessboardCandidate cands) { std::vectorChessboardCandidate merged; for (auto c : cands) { // 把当前候选的角点包围盒与已有棋盘格比较 bool mergedFlag false; for (auto m : merged) { cv::Rect curRect cv::boundingRect(c.corners); cv::Rect mRect cv::boundingRect(m.corners); if (bbox_iou(curRect, mRect) 0.6) { // 同一棋盘格合并角点后重新排序 cv::Mat H cv::findHomography(c.corners, m.corners, cv::RANSAC, 3.0); if (!H.empty()) { // 用 H 把 c 的角点投影到 m 坐标系并合并 m.corners.insert(m.corners.end(), c.corners.begin(), c.corners.end()); mergedFlag true; } break; } } if (!mergedFlag) merged.push_back(c); } cands.swap(merged); }bbox_iou大于 0.6 才触发合并是因为同一棋盘格在相邻窗口的重叠区域占比一般很高如果低于 0.6 很有可能只是两块位置靠近的独立棋盘格。findHomography用 RANSAC 而不是最小二乘是防止c的角点里混进误检点。合并完成后需要清掉重复的角点做法是按坐标排序后相邻点距离小于 1 像素的只留一个。这里有个隐含细节findHomography的输入是两套对应点但c和m的角点顺序可能不同所以直接用 RANSAC 反而更可靠。3.1.2 最大棋盘格的面积与网格尺寸判定合并完所有候选后每块棋盘格的面积可以用cv::contourArea计算角点形成的多边形面积。最大棋盘格就是面积最大的那个候选而不是行列数最多的那个——一张离相机近的 8x6 棋盘格面积可能远大于一张离得远的 10x7 棋盘格。标定内参时画面中更大的棋盘格能提供更好的畸变约束因为角点分布范围更广。// 选择面积最大的棋盘格作为主标定板 auto maxIt std::max_element( candidates.begin(), candidates.end(), [](const ChessboardCandidate a, const ChessboardCandidate b) { return a.area b.area; });选择最大棋盘格后还要重新建立角点行列索引这时不能用刚才的合并结果要基于最大棋盘格的角点重新做一次findChessboardCorners因为合并后的角点顺序是混乱的。另一种做法是利用单应矩阵把最大棋盘格的所有角点投影到网格坐标再对网格坐标排序但直接重跑原生函数更省事且网格尺寸内角点的 rows、cols在候选结构体里已经记录了。3.2 角点排序的正确姿势排序是标定能否成功的关键。OpenCV 返回的角点顺序在CALIB_CB_ADAPTIVE_THRESH等 flag 组合下通常是逐行扫描但并不保证每行方向一致。所以用重跑findChessboardCorners后得到的corners数组我会做一次鲁棒排序取第一行前两个角点连线方向做参考向量遍历所有角点按角度和投影距离分配到网格的(row, col)位置。// 按行优先排序方向由第一个点指向第二个点决定 void orderCorners(std::vectorcv::Point2f corners, int rows, int cols) { // 先按 y 粗略分桶再按 x 排 std::sort(corners.begin(), corners.end(), [](const cv::Point2f a, const cv::Point2f b) { if (std::abs(a.y - b.y) 2.0f) return a.y b.y; return a.x b.x; }); }这个实现只对畸变很小、棋盘格基本正对相机的情况有效。如果镜头畸变大同一行的角点 y 坐标差会超过 2 像素直接按 y 分桶就会串行。更稳的做法是先用findHomography把像素坐标映射到理想网格然后在网格坐标系按(row, col)排序最后把索引映射回像素坐标数组。项目里应该是用了后一种因为前面已经存了 homography。4. 从检测到标定OpenCV 相机内参标定接口与参数校验4.1 物方点与像方点的组织方式标定内参需要至少 10 组不同的棋盘格姿态图像每组图像里都要检测出棋盘格角点。物方点用std::vectorcv::Point3f每组的物方点都一样只是棋盘格在相机坐标系的位姿不同std::vectorstd::vectorcv::Point3f objectPoints; std::vectorstd::vectorcv::Point2f imagePoints; const float squareSize 25.0f; // 实际棋盘格一个格子的边长单位 mm for (int i 0; i patternSize.height; i) { for (int j 0; j patternSize.width; j) { objectPoints.back().emplace_back(j * squareSize, i * squareSize, 0.0f); } }注意这里patternSize是内角点数量比如棋盘格物理上有 10x7 个格子内角点就是 9x6。很多人把patternSize设成 10x7findChessboardCorners立刻失败因为算法要求的尺寸就是(cols-1, rows-1)。物方点 Z 轴全部设 0因为棋盘格是平面单应性约束下 Z 轴归零不会丢信息。4.2 calibrateCamera 参数精讲cv::calibrateCamera是重头戏。我常用的调用方式cv::Mat cameraMatrix, distCoeffs; std::vectorcv::Mat rvecs, tvecs; double rms cv::calibrateCamera( objectPoints, // 物方点每组都一样 imagePoints, // 像方点每次检测到的角点 cv::Size(gray.cols, gray.rows), // 图像尺寸 cameraMatrix, // 3x3 内参矩阵 distCoeffs, // 畸变系数 rvecs, tvecs, cv::CALIB_RATIONAL_MODEL cv::CALIB_THIN_PRISM_MODEL, // 标定模型 cv::TermCriteria(cv::TermCriteria::EPS cv::TermCriteria::COUNT, 30, 1e-6) );CALIB_RATIONAL_MODEL启用 8 参数有理畸变模型对广角镜头或鱼眼镜头效果好普通针孔镜头用CALIB_FIX_K3固定三阶径向畸变为 0反而更稳。CALIB_THIN_PRISM_MODEL用来建模镜头中心偏移和图像传感器倾斜如果标定板图像没有明显的梯形畸变可以去掉。两个 flag 都加上会让内参矩阵和畸变系数之间有耦合偶尔出现参数漂移我一般先跑一次不带 flag 的基础标定看 RMS如果大于 0.3 再放开畸变模型参数。TermCriteria的 EPS 设1e-6比默认的1e-8宽松但迭代次数从默认的 30 提到 50这样在畸变较大的图像上也不会因为迭代上限卡在局部最优解。4.3 重投影误差检查与图像剔除标定结果到底能不能用不能只看 RMS要逐张图算重投影误差。剔除误差最大的那张图像后再重新标定一次直到所有图像的单个重投影误差都小于 0.2 像素这一般能显著提升内参精度。// 计算每组图的平均重投影误差找出误差最大的视角 std::vectordouble perViewErrors; for (size_t i 0; i objectPoints.size(); i) { std::vectorcv::Point2f projected; cv::projectPoints(objectPoints[i], rvecs[i], tvecs[i], cameraMatrix, distCoeffs, projected); double err cv::norm(projected, imagePoints[i], cv::NORM_L2) / projected.size(); perViewErrors.push_back(err); }projectPoints用的是标定得到的内参和畸变系数以及这一组图像的rvecs[i]、tvecs[i]。误差单位是像素。如果某张图的棋盘格恰好被手指按住了一半检测出的角点定位会有偏移这张图的误差会明显高于其他图直接丢弃即可。5. 鲁棒性分析与排错光照变化、畸变下的检测稳定性5.1 光照不均是标定失败的头号杀手CALIB_CB_NORMALIZE_IMAGE只能缓解全局光照不均但面对棋盘格一半亮一半暗比如室外阳光被遮挡物切了一半的情况还不够。我一般会在检测前先做 CLAHE 局部直方图均衡cv::Ptrcv::CLAHE clahe cv::createCLAHE(2.0, cv::Size(8, 8)); cv::Mat enhanced; clahe-apply(gray, enhanced);createCLAHE的 clipLimit 设在 2.0低于 1.5 增强效果不明显高于 3.0 会在角点附近放大噪声。做 CLAHE 后再跑findChessboardCorners整体检测成功率能提高 30% 左右。代价是每张图多花 2-3 毫秒但标定本来就是离线任务。5.2 棋盘格不完整时的处理策略如果棋盘格有一边被遮挡findChessboardCorners几乎必然失败。这时可以尝试裁剪图像边缘后再检测但更推荐把这个姿态的图像直接丢弃因为不完整的棋盘格即使检测成功角点排序也会出错标定结果反而更差。项目里提到查找“所有独立棋盘格”对不完整但能检测出部分角点的区域可以把它们单独输出但不要作为标定输入。我在工程里会将完整棋盘格和不完整棋盘格分开存储只有完整棋盘格才进入标定序列。5.3 编译与调试的建议项目用 Eigen 模板库编译时建议直接开-O2加-DNDEBUG去掉断言棋盘格检测循环里的 Eigen 运算能快一倍。Magick 依赖的图像库不同会导致 BMP 读图差异建议先统一转成 OpenCV 原生imread读取格式避免颜色通道顺序不对影响灰度化。最后留一个可复用的验证命令标定完以后单独跑一遍所有图像的重投影误差分布把误差大于 0.3 像素的图像文件名打印出来。如果所有图像误差都偏大优先检查squareSize是否设成了棋盘格外边框尺寸而不是单个格子的边长——这个错误能把焦距估计抬高 8 倍但 RMS 看起来仍然很小。本文还有配套的精品资源点击获取