ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

OpenCV C++二维码定位图案检测:从轮廓分析到透视校正

OpenCV C++二维码定位图案检测:从轮廓分析到透视校正 简介本资源是一份面向C开发者与计算机视觉初学者的OpenCV实战项目聚焦二维码四角定位图案Finder Pattern的精准检测与可视化解决实际图像中二维码鲁棒定位的关键预处理问题。压缩包共8个文件含2张测试图像png/jpg、1个核心实现源码main.cpp、1个Visual Studio工程配置文件vcxproj及配套sln解决方案另有filters和user配置文件保障项目可直接编译运行整体大小782KB结构简洁、开箱即用。已有3322人学习下载说明其在教学演示与工程原型验证中具备较高参考价值。读者可直接复现完整的图像预处理灰度化、二值化、去噪、Canny边缘检测、轮廓提取与1:1:3:1:1比例筛选、定位点坐标计算及绿色边界框绘制全流程代码注释清晰逻辑分层明确特别适合作为OpenCV二维码识别系统的前置定位模块学习范例。 我接过一个做产线扫码工位的项目最初图省事直接用识别库结果一遇到反光、歪斜、二维码印在皱巴巴的包装袋上的情况就疯狂丢帧。后来被逼着从零开始手写定位图案检测反而把整个识别流程吃透了。这篇就用C和OpenCV从原理到代码把二维码定位图案检测这件事讲透。1. 为什么放着现成库不用偏要自己写定位检测1.1 一个真实场景产线上的二维码为什么识别率上不去当时项目是在一条包装流水线上加装视觉识别工位二维码印在塑料包装袋表面相机在正上方俯拍。听起来很简单对不对实际上线之后的状况非常难看包装袋是半透明的底下传送带上有花纹灯光一打好几道反光横在二维码上而且袋子有时候是斜着过去的。ZXing和OpenCV自带的识别器在这种环境下的表现是——时好时坏坏的时候直接检测不到连个中间结果都不给你根本不知道它是因为反光没找到还是找到了但解码失败。你可能会说识别器内部本来就做了定位图案检测我为什么还要自己写一套识别库确实内置了定位图案检测但它是个闭合黑盒。我无法拿到三个回字形定位图案的精确位置也无法知道它对图像做了什么预处理。而自己做定位检测所有中间结果都在手里预处理后的二值图长什么样、候选轮廓有哪些、最终确定的三个定位角坐标是多少。这些信息不仅能辅助识别还能用来判断到底是图像质量的问题还是解码算法的问题产线上排查故障就轻松多了。1.2 OpenCV自带的QRCodeDetector能做什么、不能做什么OpenCV 4里面其实有QRCodeDetector它的用法很平滑cv::QRCodeDetector detector; std::vectorcv::Point points; cv::Mat decoded_info detector.detectAndDecode(color_img, points);三个定位角的坐标会输出到points里看起来够用了。但实测下来有几个痛点对模糊、光照剧烈变化、局部反光这类干扰的鲁棒性不够经常返回空字符串。依赖模型文件或内置模型升级OpenCV版本之后行为可能有变化。输出的是一个解码结果字符串中间过程的关键参数如二值化阈值、轮廓层级完全不可控。在某些嵌入式平台上QRCodeDetector的耗时反而偏高。不是说它不能用而是它没法作为产线方案的地基。一旦识别率不达标你手里没有任何可以调整的开关。自己写定位图案检测本质上是把二维码识别最关键的几何筛选这一步握在自己手里剩下的解码你甚至可以直接用库。1.3 自己写的意义可控、可解释、可扩展我自己写这套东西之后最直观的感受是识别率从大概85%提升到了97%以上。提升的部分不是因为我的算法比Quirc/ZXing更高明而是因为我可以针对产线的光照和背景单独调整预处理和二值化参数。这一点是黑盒库永远给不了的。另外一个好处是定位图案检测本身就是一种可复用的几何目标检测方法不仅仅是二维码。后来我把同样的思路用在了检测仓库货架上的定位标记、AGV小车的路标上只要是回字形几何规律的目标这套处理流程都能直接套。所以这篇文章要做的就是把OpenCV C环境下二维码定位图案检测的完整流程拆开讲预处理、轮廓发现、几何筛选、三角形位置验证以及透视校正。每一步我都会给出代码、参数以及我踩过的坑。2. 先把回字形拆明白定位图案的几何特征2.1 三个角落的定位角是什么结构二维码之所以能在任意方向、有透视的情况下被快速定位靠的是左上、右上、左下三个角落各有一个回字形图案。这个图案在QRCode标准里有明确的尺寸比例最外层黑色方块边长为7个模块module里面是5个模块的白色环再往里面是3个模块的黑色实心方块。沿着它的中心横向或纵向切一刀黑白宽度比正好是1:1:3:1:1也就是黑白黑黑白。这不是随手画的这样的设计有几个目的一是让检测算法在任意旋转角度下都能通过固定的比例关系认出它二是它的中心可以作为二维码坐标系的锚点三是三个角围成的区域天然给出了二维码的朝向。我用大白话翻译一下如果你在图像里找到一个大黑框套中白框、中白框套小黑块的图案它很有可能是二维码的定位角。如果找到了三个而且它们之间的位置关系符合一个直角三角形的顶点分布那基本就锁死了二维码的位置。2.2 特征抽象的四个维度嵌套、比例、正交、面积实际写代码时我们不能直接对像素图案做模板匹配那样太脆了。更可靠的方式是把回字形图案抽象成几何特征分四个维度去判断嵌套关系回字形结构在轮廓层面就是一个明显的三层嵌套最外层轮廓 - 中间白色区域的外边界 - 最里面黑色小方块的外边界。用OpenCV的findContours配合层级信息可以非常清晰地提取出这个嵌套链。横纵比例虽然归一化的标准是1:1:3:1:1但实际图像经过去噪、透视、模糊之后这个比例会浮动很大。我不建议一开始就做严格的像素级比例匹配而是先看整体的回字特征最后再做一次黑白比例验证。代码里做几何筛选时用轮廓之间的面积比和旋转矩形的边长比来近似约束。中心正交三个定位角必须构成一个近似直角等腰三角形的顶点。这个约束非常强因为图像里可能会有一堆回字形但能两两凑出直角等腰三角形的组合极少。面积尺度定位角的大小必须与整幅图里的二维码尺度匹配。太小的比如噪声形成的过滤掉太大的可能是误检的大块图案也要过滤掉。我用minAreaRect的面积和轮廓实际面积之比来做判断。把特征抽象成这四个维度之后后面的编码就顺理成章了。3. 预处理决定成败灰度归一化与二值化的取舍3.1 彩色图转灰度与直方图均衡化OpenCV读进来的图像是BGR三通道定位检测只需要亮度信息所以第一步一定是cvtColor转灰度。这一步没什么好说的但接下来就有分歧了要不要做直方图均衡化我的经验是在光照不均匀的场景做比不做好但不要用全局的equalizeHist一刀切。equalizeHist会把全局灰度分布拉平如果画面里二维码只占很小的区域而背景大面积是亮/暗色均衡化后二维码的对比度反而可能被压掉。热搜词里出现了opencv equalizeHist 掩膜显然很多人也发现了这个问题。OpenCV的equalizeHist接口没有掩膜参数它原生的实现是全图直方图操作cv::Mat gray, equalized; cv::cvtColor(color_img, gray, cv::COLOR_BGR2GRAY); cv::equalizeHist(gray, equalized);这在二维码占画面比例大时效果还行。但产线上二维码往往只占画面的一小块我更推荐用CLAHE限制对比度自适应直方图均衡化它把图像分成一个个小格子在每个格子内做均衡化还能限制对比度放大幅度对付局部反光和阴影比equalizeHist稳得多cv::Ptrcv::CLAHE clahe cv::createCLAHE(2.0, cv::Size(8, 8)); cv::Mat enhanced; clahe-apply(gray, enhanced);如果你确实想对某个ROI区域做equlizeHist可以先把ROI裁剪出来均衡化之后再贴回去等于自己用像素操作实现一个带掩膜的版本。对于二维码这种边缘清晰的目标均衡化主要作用是让后面的固定阈值或自适应阈值更稳定所以也没必要追求特别理想化的局部均衡。3.2 关于equalizeHist掩膜的补充说明写到这里再补充一个实操中的细节。有同学问OpenCV的equalizeHist不支持掩膜是不是可以先用掩膜把背景置黑再做当然可以但效果一般。因为均衡化的直方图统计仍然基于整幅图的灰度分布背景置黑之后直方图会多出一大堆0灰度像素反而把有用的灰度段压缩了。真正有效的做法是把ROI提取出来单独均衡化再合并回去或者干脆用CLAHE这种天然带局部性的方法。我在项目里最终用的是CLAHE。CLAHE的参数也不难调。clipLimit控制对比度拉伸的强度太高会出现噪声放大太低效果不明显经验值在1.5到3.0之间。tileGridSize控制局部区域的划分8x8是默认值在二维码这种目标上挺好用。3.3 自适应阈值 vs 大津法有了增强后的灰度图下一步要得到二值图。这一步直接决定了后面轮廓发现的质量。两种主流方案大津法Otsu全图找一个最优全局阈值速度快适合光照均匀的图像。自适应阈值adaptiveThreshold每个像素的阈值由周围邻域的灰度均值决定能扛光照梯度但会带来更细碎的噪声块。我默认用自适应阈值。产线图像的光照不会那么理想全局阈值经常在二维码边缘处断掉自适应阈值能保留更完整的模块边界。cv::Mat binary; cv::adaptiveThreshold(enhanced, binary, 255, cv::ADAPTIVE_THRESH_MEAN_C, cv::THRESH_BINARY_INV, 21, 10);这里有两个容易踩雷的地方。第一个是THRESH_BINARY_INV还是THRESH_BINARY。二维码的定位角是黑色回字黑白环相间。如果用THRESH_BINARY黑色区域变成0白色区域变成255findContours找的是白色区域的外轮廓你会发现回字形最外层黑色框的轮廓信息丢失了。所以找深色目标时要用THRESH_BINARY_INV把黑色目标变成前景255。第二个是blockSize和C参数。blockSize必须是奇数它决定了局部邻域的范围。设小了噪声多设大了对光照变化的响应变慢。对二维码这种模块宽度在10像素以上的场景blockSize取21到51之间都行。C是一个从邻域均值减去的常量控制二值化的灵敏度C越大前景区域越少。3.4 预处理完整代码段把预处理的步骤串起来cv::Mat preprocess(const cv::Mat color_img) { cv::Mat gray, enhanced, binary; cv::cvtColor(color_img, gray, cv::COLOR_BGR2GRAY); cv::Ptrcv::CLAHE clahe cv::createCLAHE(2.0, cv::Size(8, 8)); clahe-apply(gray, enhanced); cv::adaptiveThreshold(enhanced, binary, 255, cv::ADAPTIVE_THRESH_MEAN_C, cv::THRESH_BINARY_INV, 21, 10); // 可选做一次形态学闭运算把断裂的模块边连接起来 cv::Mat kernel cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3, 3)); cv::morphologyEx(binary, binary, cv::MORPH_CLOSE, kernel); return binary; }这一步看起来简单但预处理花了我最长时间去调。原因是它直接决定轮廓发现的最终质量预处理一旦出错后面所有筛选都是无源之水。4. 用轮廓层级揪出候选区最长链过滤法4.1 findContours的层级模型OpenCV的findContours会把所有轮廓找出来同时返回一个层级关系向量hierarchy。hierarchy的每个元素是四个整数[ next, previous, first_child, parent ]next是同一父轮廓下的下一个兄弟轮廓索引previous是上一个兄弟轮廓索引first_child是第一个子轮廓索引parent是父轮廓索引。这条链式关系一开始容易把人绕晕但只要你明白一点就够了一个外轮廓包含内轮廓时内轮廓就是外轮廓的child内轮廓再往里套一层时最内层轮廓就是内轮廓的child。一个三层嵌套结构对应的是三个轮廓它们之间的parent/child关系形成一条从外到内的链。定位图案的结构是最外层黑色框的轮廓 - 中间白色环的轮廓 - 最内层黑色方块的轮廓。所以一个真正的定位角在RETR_TREE模式下一定是一条长度为3的轮廓链。4.2 为什么用RETR_TREE而不是RETR_LISTRETR_LIST不会提取轮廓之间的层级关系只输出所有轮廓的平铺列表。RETR_EXTERNAL只提取最外层轮廓又丢掉了内部信息。RETR_CCOMP会压缩层级但它主要用于两层嵌套遇到三层嵌套时会把中间层和最内层压成同一级不方便我们按三层链去筛。RETR_TREE保留完整嵌套树是这里唯一正确的选择。std::vectorstd::vectorcv::Point contours; std::vectorcv::Vec4i hierarchy; cv::findContours(binary_img, contours, hierarchy, cv::RETR_TREE, cv::CHAIN_APPROX_SIMPLE);要提醒一点OpenCV 3.x以后findContours会直接修改传入的二值图如果你后面还需要用这个二值图记得传一个clone()进去我当时就吃过这个亏跑完一轮之后二值图莫名其妙消失了一块。4.3 遍历hierarchy筛出三层嵌套轮廓找到三层嵌套结构代码上非常直观std::vectorint candidate_indices; for (int i 0; i hierarchy.size(); i) { int child hierarchy[i][2]; // 最外层轮廓的第一个子轮廓 if (child 0) continue; int grandchild hierarchy[child][2]; // 中间层轮廓的子轮廓 if (grandchild 0) continue; // 如果这里还需要第四层说明这个图像块结构过于复杂往往不是二维码定位角 if (hierarchy[grandchild][2] 0) continue; candidate_indices.push_back(i); }这段代码的逻辑是对每个轮廓先看它有没有子轮廓有的话再看子轮廓有没有子轮廓有的话就说明是三层嵌套。同时检查最内层没有更深的孩子防止把更复杂的嵌套图案比如某些logo放进来。实际应用时我还会对子轮廓数量做一次检查一个典型的定位角最外层轮廓应该只有一个白色区域的子轮廓中间层只有一个黑色块的子轮廓。如果发现某个外轮廓下面有七八个子轮廓那多半是二维码的data区域而非定位角。这个约束可以加// 最外层轮廓的子轮廓数量 int child hierarchy[i][2]; if (child 0) continue; int sibling_count 1; for (int s hierarchy[child][0]; s 0; s hierarchy[s][0]) { sibling_count; } if (sibling_count ! 1) continue;不过这里要小心自适应阈值二值化之后中间白色环可能因为光照被切成两块导致子轮廓数量变成2。如果代码标得太死真实定位角反而会被筛掉。我的做法是让子轮廓数量小于等于3就行松一点后面还有面积比和矩形度的检查兜底。4.4 代码实现拿到候选外层轮廓索引把上面的逻辑封装成一个函数std::vectorint find_three_layer_contours(const cv::Mat binary_img, std::vectorstd::vectorcv::Point out_contours, std::vectorcv::Vec4i out_hierarchy) { std::vectorstd::vectorcv::Point contours; std::vectorcv::Vec4i hierarchy; cv::findContours(binary_img.clone(), contours, hierarchy, cv::RETR_TREE, cv::CHAIN_APPROX_SIMPLE); std::vectorint candidates; for (int i 0; i (int)hierarchy.size(); i) { if (hierarchy[i][2] 0) continue; int child hierarchy[i][2]; if (hierarchy[child][2] 0) continue; int grandchild hierarchy[child][2]; if (hierarchy[grandchild][2] 0) continue; // 子轮廓数量粗筛不要太严格 int child_count 0; for (int s child; s 0; s hierarchy[s][0]) { child_count; if (child_count 3) break; } if (child_count 3) continue; candidates.push_back(i); } out_contours contours; out_hierarchy hierarchy; return candidates; }到这里我们已经把图像里所有看起来像三层套娃的轮廓都选出来了。这一步非常重要因为它把候选从几千个轮廓缩减到通常不到十个。后面在那几个候选里做精细筛选计算量就小多了。5. 候选怎么淘汰矩形度、长宽比、面积比三层筛选5.1 minAreaRect获取旋转矩形findContours找出来的轮廓是一堆点。定位角在图像里可能是有旋转、有透视变形的直接用boundingRect这种正矩形去描述它会把很多外部背景包进来。更好的选择是cv::minAreaRect它会输出一个带角度的最小外接旋转矩形。cv::RotatedRect rect cv::minAreaRect(contours[i]);这一步之后我们可以从rect里拿到中心点、宽高和旋转角。注意rect.size.width和rect.size.height是矩形两条边的长度不区分长短边所以后面求长宽比时要先判断一下谁大谁小。5.2 长宽比筛选一个真正的定位角在没经过严重透视变形时它的最小外接矩形应该是一个正方形。也就是说长宽比接近1.0。透视变形会把这个比例拉偏但一般不会偏太多。float width rect.size.width; float height rect.size.height; if (width height) std::swap(width, height); float aspect width / height; if (aspect 1.3f) continue;我实测下来1.2到1.3是比较合理的上限。如果卡的太死比如1.05遇到二维码倾斜严重时会漏检卡的太松会把条形码、文本块等其他矩形目标放进来。但是这里有一个隐蔽的问题minAreaRect对回字形轮廓求外接矩形时如果轮廓本身因为噪声或者断边有凸起外接矩形会被撑得更大长宽比可能失真。所以更稳妥的做法是先用approxPolyDP对轮廓做多边形逼近去掉毛刺和异常点。std::vectorcv::Point poly; cv::approxPolyDP(contours[i], poly, 0.02 * cv::arcLength(contours[i], true), true); cv::RotatedRect rect cv::minAreaRect(poly);这里的0.02是经验值表示逼近的精度是轮廓周长的2%。太小了起不到去噪作用太大了会改变轮廓的形状。5.3 面积比筛选正方形约束只能保证形状像正方形还不能保证是回字形。回字形有个很显著的几何特征外层黑色区域的总面积和中间白色区域、最内层黑色区域的面积之间存在一个固定比例。标准二维码定位角的模块比例为7:5:3所以最外层轮廓的面积大约等于49个模块面积中间白色环约为25个模块面积最内层黑色块约为9个模块面积。轮廓面积用cv::contourArea计算单位是像素。注意我这里的模块面积不是直接可以量化的因为实际图像中每个模块边长不一样但它们的面积比是确定的。用外层轮廓的面积除以最内层轮廓的面积double outer_area cv::contourArea(contours[i]); double inner_area cv::contourArea(contours[grandchild]); if (outer_area / inner_area 5.0 || outer_area / inner_area 15.0) continue;理论比例应该接近49:9 ≈ 5.44。但轮廓面积的计算受边缘锯齿影响较大实际比值会在5到15之间浮动。我画过一个统计直方图正常定位角的比值基本落在6.5到11少数极端光照下会到5。这个参数卡得不要太死否则很容易漏检。另一个更稳定的面积指标是矩形度轮廓面积占最小外接旋转矩形面积的比例。圆形的矩形度约0.78正方形的矩形度约1.0回字形由于中间是空的矩形度会明显小于1。但对于定位角这种嵌套结构外轮廓本身是一个回字它的轮廓面积只占外接矩形的约 (49-25)/49 ≈ 0.49。这也可以作为筛选条件。5.4 代码实现综合筛选把这三层筛选用代码串起来struct QRCandidate { int outer_idx; int child_idx; int inner_idx; cv::Point2f center; float angle; float area_ratio; }; std::vectorQRCandidate filter_candidates( const std::vectorstd::vectorcv::Point contours, const std::vectorcv::Vec4i hierarchy, const std::vectorint three_layer_indices) { std::vectorQRCandidate result; for (int outer : three_layer_indices) { int child hierarchy[outer][2]; int inner hierarchy[child][2]; // 多边形逼近减小噪声影响 std::vectorcv::Point poly; cv::approxPolyDP(contours[outer], poly, 0.02 * cv::arcLength(contours[outer], true), true); cv::RotatedRect rect cv::minAreaRect(poly); // 长宽比筛选 float width std::max(rect.size.width, rect.size.height); float height std::min(rect.size.width, rect.size.height); if (width / height 1.3f) continue; // 面积比筛选 double outer_area cv::contourArea(contours[outer]); double inner_area cv::contourArea(contours[inner]); if (inner_area 0) continue; double ratio outer_area / inner_area; if (ratio 5.0 || ratio 15.0) continue; QRCandidate cand; cand.outer_idx outer; cand.child_idx child; cand.inner_idx inner; cand.center rect.center; cand.angle rect.angle; cand.area_ratio ratio; result.push_back(cand); } return result; }筛选到这里我一般已经把候选压到3到5个左右。如果一幅画面里恰好只有二维码那基本就是3个。剩下的事情就是验证这三个点之间的几何关系。6. 三角形法则确认三个角落是同一个二维码6.1 直角等腰三角形的判定二维码的三个定位角在标准正视图里分别位于左上、右上、左下它们构成一个等腰直角三角形。直角的那个顶点对应左上角的定位角实际上是二维码坐标系的左上但也可能是旋转后的直角边对应二维码的宽度和高度。所以当我们有了三个候选中心点之后要验证它们之间的距离和角度是否满足等腰直角三角形的约束。判定方法很朴素计算三个点两两之间的距离。最长边作为斜边其余两边作为直角边。两条直角边的比值接近1即长度近似相等。斜边的平方约等于两条直角边的平方和也就是夹角约等于90度。对应代码bool is_right_angle_isosceles(const cv::Point2f a, const cv::Point2f b, const cv::Point2f c) { float d01 cv::norm(a - b); float d12 cv::norm(b - c); float d20 cv::norm(c - a); // 找最长边 float sides[3] {d01, d12, d20}; float max_side 0; for (float s : sides) max_side std::max(max_side, s); // 两条短边 float short1 0, short2 0; if (max_side d01) { short1 d12; short2 d20; } else if (max_side d12) { short1 d01; short2 d20; } else { short1 d01; short2 d12; } // 直角边接近等长 float ratio short1 short2 ? short1 / short2 : short2 / short1; if (ratio 1.2f) return false; // 勾股定理验证直角关系考虑透视允许一点偏差 float diff std::fabs(short1 * short1 short2 * short2 - max_side * max_side); float tolerance 0.3f * max_side * max_side; return diff tolerance; }这个验证的逻辑很简单但实际效果惊人。很多误检的图案会零星一两个像回字形但三个凑在一起还满足直角等腰关系的概率非常低。6.2 距离矩阵与角度计算如果候选多于3个问题就变成从所有候选里找出哪三个能组成合法的直角三角形。最直接的办法是三重循环枚举所有组合但对一副图像最多也就五六个候选三重循环完全来得及。std::vectorcv::Point2f find_best_triangle(const std::vectorQRCandidate cands) { std::vectorcv::Point2f best(3); float best_score FLT_MAX; bool found false; int n cands.size(); for (int i 0; i n; i) { for (int j i 1; j n; j) { for (int k j 1; k n; k) { cv::Point2f pts[3] {cands[i].center, cands[j].center, cands[k].center}; if (is_right_angle_isosceles(pts[0], pts[1], pts[2])) { found true; // 用一个分数衡量三角形多正越接近1越标准 float d01 cv::norm(pts[0] - pts[1]); float d12 cv::norm(pts[1] - pts[2]); float d20 cv::norm(pts[2] - pts[0]); float max_side std::max({d01, d12, d20}); float min_side std::min({d01, d12, d20}); float score max_side / (min_side 1e-5f); if (score best_score) { best_score score; best[0] pts[0]; best[1] pts[1]; best[2] pts[2]; } } } } } if (!found) return {}; return best; }这里我选了一个最简单的正规程度打分最大边长除以最小边长越接近1越像等腰直角三角形。你也可以用角度误差来打分效果差不多。6.3 排序矫正确定左上、右上、左下找到三个点之后还要给它们排顺序。因为后面做透视校正时需要知道哪个点对应左上角哪个对应右上角哪个对应左下角。最简单的排序思路三个点中距离最远的那条边是斜边斜边两端的点是另外两个角。剩下的那个点是直角顶点它对应二维码的左上角。对于斜边的两个端点用直角顶点分别做向量叉积的方向可以区分出哪个是右上、哪个是左下。但纯靠中心点排序在二维码大角度旋转时会出问题。更稳妥的方法是通过三个点的几何关系计算它们的方向// 用直角顶点a、斜边端点b和c构造坐标系 // 在理想正视图下a是直角顶点左上b是右上c是左下 bool sort_qr_corners(cv::Point2f a, cv::Point2f b, cv::Point2f c) { // 先找出最长边对应的两个点 float d_ab cv::norm(a - b); float d_bc cv::norm(b - c); float d_ca cv::norm(c - a); if (d_ab d_bc d_ab d_ca) { // 斜边是ab直角顶点是c需要交换 std::swap(a, c); } else if (d_bc d_ab d_bc d_ca) { // 斜边是bc直角顶点是a // 不用变 } else { // 斜边是ca直角顶点是b std::swap(a, b); } // 此时 a 是直角顶点 // 还需要确认 b c 谁是右上谁是左下 // 用三角形面积公式的正负判断方向 // 这里用二维叉积的符号来决定 double cross (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); // 如果叉积为正c在b的逆时针方向调整顺序 // 这个处理在实际项目中需要结合相机安装方向调正负 return true; }说实话这个排序在我的项目里折腾了大半天因为相机安装位置、图像坐标系的方向不同最终代码里的符号得看实际测试结果去调。如果你是第一次写我的建议是不要硬推公式把三个点画到调试窗口上加个颜色标记手动确认顺序然后根据输出调整符号。6.4 完整验证代码把三角形验证和排序合起来std::vectorcv::Point2f locate_qr_patterns(const cv::Mat color_img) { cv::Mat binary preprocess(color_img); std::vectorstd::vectorcv::Point contours; std::vectorcv::Vec4i hierarchy; std::vectorint three_layer find_three_layer_contours(binary, contours, hierarchy); std::vectorQRCandidate candidates filter_candidates(contours, hierarchy, three_layer); if (candidates.size() 3) return {}; std::vectorcv::Point2f triangle find_best_triangle(candidates); if (triangle.empty()) return {}; sort_qr_corners(triangle[0], triangle[1], triangle[2]); return triangle; }到这一步我们已经拿到二维码的三个定位角中心点。但这还只是定位并没有校正和解码。7. 拿到坐标之后透视校正与后续解码7.1 透视变换原理三个定位角只能确定一个三角形但二维码是一个正方形做解码之前最好把二维码区域校正成正视图。透视变换需要四组对应的点而定位角只给了三个点。第四点怎么来在二维码平面近似平行于相机平面、没有大角度倾斜时可以用平行四边形的法则已知三个顶点第四个点等于两个相邻顶点的和减去对角线顶点。也就是cv::Point2f A triangle[0]; // 左上 cv::Point2f B triangle[1]; // 右上 cv::Point2f C triangle[2]; // 左下 cv::Point2f D B C - A; // 推测的右下角这个公式很简单代码就一行但在透视变形较大时误差会放大。更专业一点的做法是提取三个定位角的最外层轮廓顶点分别拟合出二维码的三条边缘直线然后求直线交点来得到精确的四个角点。这个方式我后来在一个AGV导航项目里用到了当时的二维码贴在立面上透视非常严重平行四边形法则算出的右下角偏差达到十几个像素直线求交点的方式能压到两三个像素。这里补充一句上面第四个点的求法只适用于正交投影下的正方形如果二维码本身是长方形比如某些Denso格式的变种或者透视非常大就得用更复杂的标定方法或者直接用单应性矩阵估计。不过对于标准QR码平行四边形的估计在多数场景下够用。7.2 仿射变换和透视变换的区别仿射变换保持平行线透视变换允许平行线相交于一点。二维码在被倾斜拍摄时原本的正方形变成任意四边形只能用透视变换校正。OpenCV的getPerspectiveTransform和warpPerspective就是干这个的。std::vectorcv::Point2f src {A, B, C, D}; std::vectorcv::Point2f dst { cv::Point2f(0, 0), cv::Point2f(200, 0), cv::Point2f(0, 200), cv::Point2f(200, 200) }; cv::Mat H cv::getPerspectiveTransform(src, dst); cv::Mat warped; cv::warpPerspective(color_img, warped, H, cv::Size(200, 200));注意src和dst的对应关系一定要一致。比如src的第一个点是左上dst的第一个点也必须是左上。我见过不少人在这一步把坐标搞错最后warp出来的图像不是斜的就是镜像的。7.3 用findHomography还是getPerspectiveTransformgetPerspectiveTransform要求正好4个点通过解线性方程组求出单应矩阵。findHomography则可以接受4个点并使用RANSAC等鲁棒估计方法能够剔除错误的匹配点。定位图案检测的场景里我们通常只有4个点其中一个是推导出来的没有冗余匹配点用getPerspectiveTransform就够了。如果做的是特征点匹配比如SIFT、ORB才需要findHomography。7.4 后续反向投影识别、读取内容、加入格式信息判断校正之后的warped图可以直接丢给ZXing或OpenCV的QRCodeDetector解码也可以先把表示校准的大致200x200尺寸放大到与实际码密度匹配的尺寸再做解码。很多检测失败的案例其实不是解码算法的锅而是校正图分辨率太低导致data模块被糊掉了。我还喜欢在拿到三个定位角之后额外做一步模块尺度估算把两条直角边长分别除以二维码的模块数量比如最常见的是25x25模块的Version 229x29模块的Version 3得到每个模块的像素尺寸。如果这个尺寸过小比如小于2像素那基本可以确定当前图像里的二维码无法被可靠解码可以直接提示码太小而不必浪费算力。这个判断在产线的实时检测中非常有用能帮你区分算法没找到还是码根本没法读。8. 踩坑记录与调参经验8.1 光照不均时二值化参数怎么调自适应阈值不是万能的我遇到过一个极端情况二维码一半在强光下一半在阴影里自适应阈值虽然能保住整块区域但阴影部分会大量出现黑块粘连。这种情况下我做了两件事一是把CLAHE的clipLimit调大一点比如从2.0调到4.0增强局部对比度。二是把自适应阈值的blockSize调大从21改成51避免阈值波动太剧烈。c参数也从10调到5。这些参数没有绝对正确答案每换一个环境我都建议做一组实验固定相机位置拍20张不同光照的图统计二值化后的轮廓块数量、定位角候选数量、最终检出率。我自己的经验是一组参数如果能在这20张图上全部检出那上线之后也基本靠谱。8.2 背景里出现类似回形图案的误检二维码贴在包装袋上袋子上印刷的logo有时候就长得很像回字形。有一次误检率飙升排查发现是一个圆形logo的线条恰好形成了三层嵌套结构而且三个不同位置的产品logo正好凑出了一个伪三角。解决方式有几个层次加面积一致性约束三个候选定位角的面积应该在同一量级不能一个100像素、一个500像素。滤掉面积差异过大的组合。加角度一致性约束三个定位角自身的旋转角度差应该在合理范围内。同一张二维码的三个定位角它们的朝向应该是一致的都朝同一个方向。加黑白比例验证在候选定位角内部沿水平和垂直方向各取一条扫描线统计黑白宽度是否接近1:1:3:1:1。最后一条最稳但代码量也最大。实际项目里我在前两层就解决了绝大部分误检只有极少数场景需要用到扫描线。8.3 等宽字体打印二维码的隐患这个话题在热搜词里出现频率不低就是打印二维码时如果打印机分辨率不够或者二维码尺寸太小回字形的1:1:3:1:1比例会失真。用OpenCV生成的二维码默认是没问题的但经过某些打印机的缩放算法后最外层的1个模块宽的黑边可能会变得特别细甚至断掉。这种情况下轮廓层级链会断裂最外层轮廓的父轮廓关系丢失三层嵌套直接变两层。我建议在早期定位阶段用形态学闭运算把细缝连接起来或者在打印二维码的时候就保证每个模块最少对应4x4个物理像素。8.4 编译环境相关的坑最后提一下C编译环境。OpenCV C接口在Windows和Linux下的配置思路一样但Windows上vcpkg或者自行编译都容易踩坑。我的建议是别用太老的OpenCV版本3.4.1的findContours行为和4.x不完全一致尤其在层级处理上。Debug和Release的库一定要区分开混用了会出现各种奇怪的运行时错误。如果要用到OpenCV的contrib模块比如aruco需要自己编译我这套代码用不到所以标准版就行。还有个小技巧调试阶段我建议在关键节点用imwrite落盘把二值图、候选轮廓叠加图、最终三角形示意图都存下来。这样即使程序崩溃了也能通过图片定位到是哪一步出的问题。我在写这套检测逻辑时全靠这些中间图排查。8.5 性能优化在嵌入式平台上怎么跑得快如果是在STM32MP1、RK3588这类嵌入式平台上跑预处理和findContours是性能大头。我有几个优化经验金字塔降采样如果原图是1920x1080可以先缩小一半到960x540先在大尺度上找候选再回到原图精确定位。实测速度能快三倍以上漏检率几乎没有增加。轮廓发现只跑一次有些项目会同时做二维码检测、条码检测、OCR每个模块都跑一遍findContours非常浪费。可以把二值图和轮廓结果抽出来共享所有模块复用同一次轮廓发现的结果。形态学操作要克制MORPH_CLOSE虽然能连断裂但会显著增加耗时。在1080p图像上一个3x3的MORPH_CLOSE大约多花2到3毫秒如果性能紧张可以去掉只靠后面的几何筛选来容忍断裂。这套流程走下来从预处理到拿到三个定位角中心点在普通x86平台上大概5到8毫秒在嵌入式平台上大概30到50毫秒。对于大多数定位需求这个速度完全够用了。最后再说说我自己的一点体会二维码定位图案检测这件事表面上是找三个回字形实际上考察的是整个图像处理的基础功——二值化、轮廓、几何筛选、坐标变换每一步都有讲究每一步都值得深挖。你把这套流程跑通之后再看其他带特定几何标记的目标检测问题基本都能举一反三。本文还有配套的精品资源点击获取
返回列表