简介:基于OpenCV与QT实现的啤酒瓶口缺陷检测C++源码,面向机器视觉初学者与工业质检应用开发者,可用于识别瓶口外环破损、内环破损、缺口等常见缺陷。压缩包共29个文件,以6个cpp核心源码与14张png测试图像为主,另含头文件、UI设计文件、Qt工程配置等,整体仅4.73MB,代码与样本分离便于直接查阅。已有216人学习下载,具备一定的参考价值。源码完整覆盖灰度化、高斯滤波、自适应阈值、形态学操作、连通区域查找、最大轮廓提取、面积周长与圆形度计算、质心定位及缺陷判断等步骤,并集成QT界面展示检测结果,方便读者快速跑通流程、比对不同瓶口样本的效果。
1. 一瓶啤酒的瓶口,为什么值得你用 C++ 重写一遍检测逻辑
在啤酒灌装线上,瓶口缺陷是典型的“不致命但很致命”的质量问题:崩边会让瓶盖密封不严,裂纹在加压时漏气,脏污直接影响灌装卫生。很多小厂还在靠老师傅用眼睛对着灯光看,看两小时就疲劳,疲劳就漏检,漏检的酒瓶到了消费者手里就成了投诉和退货。我接过一个现场项目,一条线每分钟过 600 个瓶子,人工复查的速度根本跟不上,而且不同的人判定的松紧程度还不一样。基于OpenCV+QT实现的啤酒瓶口缺陷检测C++源码,解决的就是这类场景:相机拍下瓶口图像,OpenCV负责找缺陷,QT负责把结果和调试界面展示出来,C++保证每一帧处理在几十毫秒内完成。这篇文章面向正在入门工业视觉、或者刚拿到一份相关源码想改造的开发者,从原理到落地,把这条路讲透。我不会带你去抄某个项目,而是带你按同一套思路自己搭一遍,让后续换了瓶型也改得动。不管你是学生做课题,还是在产线做集成,按照这个方法,你都能把一个只能看demo的代码,改造成真正能扛住三班倒的检测程序。
2. 瓶口缺陷检测的成像与算法选型:先看懂瓶子,再写代码
2.1 瓶口缺陷类型与成像特征:知道你要在图上找什么
要写检测代码,第一件事不是调函数,而是搞清楚缺陷长什么样子,不然你真的就是盲人摸象。我一般把瓶口缺陷分成四类,每一类的成像特征完全不一样。
- 崩边:瓶口边缘的缺口,在灰度图上表现为边缘局部断裂,形状类似锯齿或月牙。这种缺陷在有背光时最明显,边缘会少一块,形成很陡的灰度跳变。如果是轻微的崩边,只有一两个像素宽,这时候需要高分辨率相机,或者把展开图放大几倍再看。
- 裂纹:从瓶口向外延伸的细线,在背光下是暗线,在环形光下可能是亮线,取决于裂纹方向与光源的夹角。裂纹的麻烦在于它的对比度不稳定,同一个瓶子旋转一下角度,裂纹可能从可见变成不可见。所以现场如果有旋转机构,建议从两个方向各拍一次。
- 气泡:玻璃内部的气泡,圆形或椭圆形,在透射光下比周围亮。气泡大小不一,有的只有零点几毫米,在图像上也就是几个像素。气泡通常不影响密封性,但如果标准严格,也需要检出来。
- 脏污:油渍、灰尘附着,表现为低灰度区域,边缘模糊。脏污和气泡相反,它是暗色块,而且形状不规则。最麻烦的是油污,灰度值和玻璃反光处的灰度接近,很容易造成误检。
成像方式直接决定算法难度。最常见的是背光方案:光源放在瓶口下方或背面,相机从上往下拍。这样瓶口轮廓形成高对比度,缺陷区域会打破边缘的平滑性。另一个常用方案是环形光,从上方斜射,适合看瓶口端面的细小裂纹和脏污。下表我整理了一下两种打光方式的适用性:
| 打光方式 | 相机位置 | 适合缺陷 | 短板 |
|---|---|---|---|
| 背光(透射) | 瓶口正上方 | 崩边、裂纹轮廓 | 难以分辨表面脏污和内部气泡 |
| 环形光(反射) | 瓶口斜上方 | 端面裂纹、脏污、划痕 | 玻璃反光造成高光区域,需要多角度 |
如果你的源码里只给了一个阈值分割,先别急着加功能,先确认图片是背光还是表面光,不同光照下同一段代码结果完全不同。我见过一个开发者在背光图上用固定阈值分割脏污,结果把瓶口高光边缘全当成了缺陷,一个合格品都过不去。
2.2 为什么选 OpenCV 而不是一上来就用深度学习
很多人看到“缺陷检测”就想到神经网络,但瓶口检测这种场景,OpenCV 的传统视觉方法往往更合适。原因有三。
第一,瓶口缺陷的种类相对固定,形态规则,用边缘、梯度、连通域就能表达。传统算法可以清晰解释每一个判定条件,现场调试时能直接告诉工艺员“这个瓶口多了一处缺口,面积超出阈值”,而模型只能给一个置信度。在工厂里,可解释性很重要,产线工人和品控要能看懂你判 NG 的理由,不然他不敢信任这台机器。
第二,样本量不够。一个瓶型真实缺陷样本可能只有几百张,训练一个有效的深度模型需要更多,而且要标缺陷轮廓,标注成本高。你花一周标数据,训练出来还不一定收敛,而传统算法一天就能写完并调出初步结果。
第三,速度。OpenCV 的 C++ 实现,一帧 640×480 的灰度图,从读取到输出结果常规在 5 到 20 毫秒,CPU 就能跑。深度模型通常需要 GPU 或者强优化,产线工控机不一定有这个条件。就算有 GPU,工业现场的稳定性也是大问题。我曾在项目里见过因为显卡驱动更新导致检测程序挂掉的情况,传统视觉就没有这种烦恼。
还有一个很实际的问题:有人会问,Python 也能调 OpenCV,为什么源码要选择 C++?产线检测程序往往需要和 PLC 通信、走串口或者以太网,还要保证 7×24 小时不崩溃。Python 脚本在长时间运行后的内存碎片和 GIL 锁问题,会让你在关键时刻掉链子。C++ 的确定性内存管理、直接调用相机 SDK 的能力,以及 Qt 自带的界面框架,都更适合工业上位机。尤其是当你需要把检测结果硬实时触发剔除器时,C++ 的微秒级响应比 Python 更让人放心。
当然,深度学习并不是不能用,但在这个标题的语境下,OpenCV 方案更容易落地、更容易调试、也更容易让客户接受。等你把传统方案跑通了,再决定要不要用模型替换某个环节,比如用分割模型去掉复杂的纹理背景。我的看法是,先用传统视觉把产品的物理边界摸清楚,再决定是否引入模型,这样你的模型输入也是经过筛选的,效果反而更好。
2.3 检测流水线的总体框架:从摄像头到结果输出的五段式
整个检测流程我习惯分成五段,每一段对应源码中的一个类或一个函数,方便移植:
图像采集:相机拍照,或者从视频流抓帧。源码里这个阶段往往是读一个固定路径的图片,或者调用相机 SDK 的接口。如果你要改成真实相机,只需要替换这一个模块。采集模块往往要考虑触发模式,是硬触发还是软触发。硬触发时,PLC 给一个信号,相机立刻抓拍,图像与瓶体位置严格同步;软触发则是程序控制相机自己拍。源码里默认读固定图片,但真实项目中必须抽象出一个接口,方便后续接相机。
预处理:把彩色转灰度、降噪、增强对比度。这里要小心,瓶口是玻璃材质,反光强烈,滤波核太大会把细小裂纹抹掉,太小压不住噪声。我常用的组合是高斯滤波 3×3 加灰度化,不对图像做过强的增强,保持原始特征。预处理还包括对图像进行畸变校正。如果使用广角镜头,瓶口在图像边缘会有畸变,需要用相机标定参数做 undistort。这一步可做可不做,但如果相机离瓶口很近,畸变会造成展开图边缘波浪,波浪被误判为缺陷。这就是有的源码在预处理中多了个
cv::undistort的原因。ROI 定位:找到瓶口中心和外半径。常用的手段是霍夫圆检测,或者利用机械定位的固定坐标。这一步决定了后面所有检测的可靠性,是整套代码的“地基”。一个常见的误区是追求圆心的绝对精度,其实对于展开算法来说,圆心误差几个像素可以通过扩大半径容忍,只要半径包含完整的瓶口环带即可。定位除了找圆心,还要估计尺度,因为瓶口距离相机的高度可能会变化。如果瓶子高度有公差,图像中的瓶口直径会在几个像素内波动。展开图的 rings 参数要对应实际物理尺寸,否则检测阈值无法统一。所以定位阶段最好输出像素/毫米的换算系数。
边缘提取与展开:在环形区域上沿径向搜边缘,然后把圆形区域展开成矩形带,把“找缺陷”从二维变到一维,大大降低判定难度。展开的像素插值方式有双线性和最近邻两种,双线性更平滑,最近邻更快,我建议用双线性,因为瓶口边缘亚像素信息有用。展开的角度起点也很关键。如果每帧都从 0 度开始,但瓶子每次旋转角度不一样,展开图里缺陷的位置会漂移。不过这不要紧,因为我们不比较帧与帧的差异,只看展开图内的边缘是否平滑。
缺陷判定与输出:对展开后的边缘图做投影、连通域分析,和预设阈值比较,产生 OK/NG 信号,同时把结果画在界面上,并且发送给 PLC 或分选机构。判定结果除了 OK/NG,最好还附带缺陷类型和坐标。坐标可以在展开图坐标系中给出,也可以通过逆映射还原到极坐标角度和半径,方便机械手做标记。这一步不一定写在源码里,但输出一根坐标是自动化集成的关键。
这五段中,第四步展开是很多人拿到的源码里核心的精髓。不展开,直接在整个圆环里找缺陷,会受瓶口圆度、中心偏移影响,误判率很高。展开后,瓶口边缘变成一条近似的直线,缺陷就是直线上的“凸起”或“断裂”,非常好处理。而且展开后的矩阵可以像普通图像一样做滤波、形态学,很多 OpenCV 的标准函数都能直接复用。
我常跟人说,拿到任何一份瓶口检测源码,先找“极坐标展开”或“展开矩阵”这个函数。没有这个函数,大概率只是拿霍夫圆检测的半径波动来冒充缺陷检测,换个瓶子就废了。真正的展开函数应该能把环形区域按角度和半径均匀采样,输出一个标准的矩形图像。判断标准很简单:你看展开图的边缘线是不是一条水平直线,如果是,说明展开做对了;如果是一条歪歪扭扭的曲线,说明圆心没找准或者展开参数不对。
3. QT + OpenCV 工程搭建:从空白窗口到能加载图像的骨架
3.1 环境配置与版本组合:先解决一堆玄学报错
这个标题里是 C++ 源码,所以我默认你用 Qt Creator 或 Visual Studio + Qt 插件。我最常用的是 Qt 5.15.2 + MSVC 2019 64 位 + OpenCV 4.5.x,这个组合开发调试最省事,网上资料也多。注意这里不是推荐最新版,而是求稳。工业项目不追求新,只追求兼容性好,第三方库版本太新往往导致编译链出问题。
配置的关键点有两个。第一是位数必须一致:Qt 编译器、OpenCV 库、你正在编译的目标平台必须是相同位数。混用 32 位库和 64 位程序,链接时会出现一堆无法解析的外部符号。这类报错里最常见的包括LNK2019、LNK2001,你仔细看错误信息,能发现它找不到的是 OpenCV 的函数符号,基本就是位数或者库名不匹配。
第二是调试版和发布版的库要分开:OpenCV 一般提供 opencv_world450.lib(release)和 opencv_world450d.lib(debug)。如果 debug 程序连了 release 库,运行时大概率崩溃在 Mat 析构上,而且不好定位。我一般会在 pro 文件里按 CONFIG 动态选择库名。很多初学者图省事,只配 release 库,然后在 debug 模式下调试,结果一进 cv::imread 就崩,这就是典型的自找苦吃。
下面是一段 Qt 的 .pro 文件配置,适用于我自己的常见目录结构:
QT += core gui greaterThan(QT_MAJOR_VERSION, 4): QT += widgets TARGET = BottleNeckInspector TEMPLATE = app CONFIG += c++11 # OpenCV 路径请换成你自己的实际安装目录 INCLUDEPATH += D:/opencv/4.5.2/include LIBS += -LD:/opencv/4.5.2/x64/vc15/lib CONFIG(debug, debug|release) { LIBS += -lopencv_world452d } else { LIBS += -lopencv_world452 }这段配置的核心是那行CONFIG(debug, debug|release),它能把 debug 和 release 的库自动切换,避免链接错版本。注意 opencv_world 是合并后的库,如果你用的 OpenCV 没有 world 版本,需要把那些带模块名的 lib 全部列进来,比如 opencv_core、opencv_imgproc、opencv_highgui。这时候理论上是能编译过的,但链接速度会慢一些,而且要注意模块之间的依赖顺序,静态链接时顺序错了也会报解析错误。
配置完成后,先写一个最小程序,加载一张图片并用 Qt 窗口显示。如果这一步能跑,环境就通了。我见过太多人一上来就编译整个检测工程,报错几百条,最后发现只是库路径配错,浪费时间。最小验证代码非常简单:
#include <QLabel> #include <QApplication> #include <opencv2/opencv.hpp> int main(int argc, char *argv[]) { QApplication app(argc, argv); cv::Mat img = cv::imread("E:/test.bmp"); cv::imshow("test", img); // 这一步调用 OpenCV 的窗口 return app.exec(); }如果这段代码能弹出 OpenCV 原生窗口,说明 OpenCV 库没问题。但工业界面通常用 Qt 窗口显示,下一步就是验证 Qt 与 OpenCV 的图像转换。这一步的坑我放到 3.3 节细说。
3.2 工程结构设计:把采集、检测、界面三个模块解耦
拿到源码后先看目录结构,再看类设计。一个能长期维护的瓶口检测工程,至少拆成三个模块:
- 采集模块:负责相机 SDK 封装,输出 cv::Mat。模拟时可能是一个视频文件循环播放,或者一个图片目录读取器。用真实相机时,这里要处理相机的曝光、触发、帧率,最好在独立线程里采集,不要阻塞界面线程。
- 检测模块:输入 Mat,输出结构体 DetectionResult,里面包含是否合格、缺陷类型、缺陷位置、耗时。这个模块不依赖 QT,纯 OpenCV,方便做单元测试。我通常把它编成静态库,这样界面和检测逻辑可以分别编译。
- 界面模块:负责交互,包括图像显示、参数调节、结果统计、日志输出。界面模块不参与算法,只负责把结果显示出来和把参数传给检测模块。
我习惯用一个单例Config保存所有可调参数,比如霍夫圆的最小半径、边缘阈值、面积阈值。这样现场调试时只需修改配置文件或界面上的 spinbox,不需要重新编译。很多源码为了图省事,把参数写成全局变量,改一处要全文搜索替换,这就是设计上的坑。你按三模块重构,后续加相机、加报表都会很顺。我甚至见过一份源码,把检测算法写在 QWidget 的 paintEvent 里,界面刷新一次就检测一次,这完全是饮鸩止渴。
下面的类声明是我常用的检测模块接口:
struct BottleNeckResult { bool ok; // 是否合格 int defectType; // 0无缺陷 1崩边 2裂纹 3气泡 4脏污 int defectArea; // 缺陷像素面积,用于阈值判断 double elapsedMs; // 单帧耗时 }; class NeckDetector { public: NeckDetector(const DetectorConfig& cfg); BottleNeckResult detect(const cv::Mat& src); void setConfig(const DetectorConfig& cfg); private: bool locateRoi(const cv::Mat& gray, cv::Mat& roiMask); bool polarUnwrap(const cv::Mat& gray, const RoiInfo& roi, cv::Mat& unwrapped); int findDefects(const cv::Mat& unwrapped, cv::Mat& viz); DetectorConfig cfg_; };接口设计得简单,detect 方法传入一帧图,返回结果。内部的 locateRoi 负责定位,polarUnwrap 负责展开,findDefects 负责找缺陷。这样做的好处是你可以对每个阶段单独调参和验证,比如用 OpenCV 的 imshow 把每一阶段中间结果显示出来。源码里如果没有这种逐步可视化的函数,我建议你自己加,后面调试会快很多。具体做法是加一个debugShow(bool enable)开关,开启时会在每一步弹出 imshow 窗口,关闭时不影响检测速度。这个开关在生产现场非常有用,你不需要断点就能看到中间量。
3.3 Mat 与 QImage 转换:不要直接在界面上画 Mat
QT 的 QPixmap 不能直接兼容 cv::Mat,因为两者的内存布局不同。直接做一次拷贝或转换是必须的。最常见的坑是颜色通道顺序:OpenCV 是 BGR,QImage 通常是 RGB,不转换的话,蓝色和红色会互换,显示出一张诡异色图。我见过有人用cv::cvtColor转成 RGB 之后还是不对,原因是 QImage 的 Format 没有选对。
我一般这样封装:
QImage cvMatToQImage(const cv::Mat& mat) { switch (mat.type()) { case CV_8UC3: { QImage img(mat.data, mat.cols, mat.rows, mat.step, QImage::Format_RGB888); return img.copy().rgbSwapped(); // 关键:BGR -> RGB } case CV_8UC1: { QImage img(mat.data, mat.cols, mat.rows, mat.step, QImage::Format_Grayscale8); return img.copy(); } default: return QImage(); } }注意rgbSwapped()这步,不可省略。另外img.copy()也是必要的:如果直接使用img,底层数据仍由 Mat 管理,一旦 Mat 被释放,QImage 变成野指针,界面刷新时随机崩溃。这一步就是很多人说的“QT 里图像显示动不动闪退”的根源之一。mat.step要传给 QImage,因为 Mat 的行可能有 padding,不传 step 会导致图像倾斜。
如果图像分辨率很大,比如 2000×2000,每帧转 QImage 再在 QLabel 中缩放显示会比较费时。我通常会在界面显示时强制缩放到固定尺寸,检测仍然用原图。这个缩放可以用 Qt 的smoothTransform,也可以用 OpenCV 的 resize,效果差别不大,但要注意界面缩放不影响检测精度。另外,QLabel 显示 QImage 时,需要设置setScaledContents(true),否则图片超出控件大小会被裁掉。还有一个实用技巧:在 QLabel 上叠加缺陷标记框,可以用一个透明 QWidget 覆盖在 label 上,或者直接把检测结果画在 Mat 上再转换。我推荐后者,因为同步方便,而且保存图像时连框一起保存,便于追溯。
4. 瓶口缺陷检测核心实现:定位、分割与判据
4.1 ROI 定位:先找瓶口中心和外半径,别让整个图像干扰你
检测的第一步是把瓶口从背景里摘出来。我用的是“灰度投影 + 边缘峰值”而不是直接霍夫圆,因为霍夫圆对参数太敏感,现场换一个光照,圆的边界就飘。当然,并不是说霍夫圆完全不能用,而是你需要理解它的脾气。cv::HoughCircles的参数有dp、minDist、param1、param2、minRadius、maxRadius,每个参数都有一层含义。param2过低会找出很多假圆,过高则找不出真圆。在瓶口这种高反光场景,边缘点里混着大量瓶体上的纹理,霍夫圆的结果常常让你摸不着头脑。
典型做法:先对原图做灰度化,然后用cv::threshold把暗背景分割出去,得到二值掩膜。瓶口通常是亮的,背景是暗的。但玻璃瓶口不是均匀亮,有的部位透光强,有的部位反光弱,OTSU 自动阈值能处理大多数情况。如果你拿到的源代码里没有这一步,而是直接对全图做 Canny 找圆,那么随后调整参数的痛苦你会深刻体会。
// 4.1 locateRoi: 基于轮廓外接圆定位瓶口 bool locateRoi(const cv::Mat& gray, cv::Mat& roiMask, RoiInfo& roi) { cv::Mat bin; // 暗背景 -> 瓶口亮区域,用 OTSU 自动阈值 cv::threshold(gray, bin, 0, 255, cv::THRESH_BINARY | cv::THRESH_OTSU); std::vector<std::vector<cv::Point>> contours; cv::findContours(bin, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE); // 找最大连通域,它就是瓶口区域 int maxIdx = -1; double maxArea = 0; for (int i = 0; i < contours.size(); ++i) { double a = cv::contourArea(contours[i]); if (a > maxArea) { maxArea = a; maxIdx = i; } } if (maxIdx < 0) return false; cv::RotatedRect r = cv::minAreaRect(contours[maxIdx]); roi.center = r.center; roi.radius = std::max(r.size.width, r.size.height) * 0.6; // 外半径略大于轮廓 roiMask = cv::Mat::zeros(gray.size(), CV_8UC1); cv::circle(roiMask, roi.center, roi.radius, cv::Scalar(255), -1); return true; }这里用 OTSU 自动阈值,而不是固定写死 100 或 200,是为了适应现场光照变化。但 OTSU 在瓶口反射强烈时可能失效,因为反射区域和瓶口本身都属于高灰度,OTSU 会把它们合并。所以我用了最大连通域和最小外接圆,把反射噪点当作小连通域过滤掉。注意半径取了外接矩形长边的 0.6 倍,因为瓶口并不是一个完美的圆,取 0.6 是为了包住瓶口外缘,同时把背景花瓣形干扰排除。你可以看到,我并没有执着于完美圆,而是给后面展开算法留出余量。实际上,展开算法天然容忍圆心偏差和半径误差,只要误差不超过环带宽度的一半就行。
如果你不想用轮廓法,也可以用“径向灰度投影”做精细定位:在粗圆心附近,沿多个方向扫描灰度变化,找到边缘点,再用最小二乘拟合圆。这个方法的鲁棒性比霍夫圆好,因为它利用了“瓶口大致圆形”的先验,而不是让算法在整幅图里随机猜测。代价是代码量多一些,但很值得。我在现场用的就是这种方法,核心逻辑是:先求粗中心,然后做 16 条辐射线扫描,对每条线求最陡的灰度跳变位置,再把这些位置拟合一个圆。
4.2 极坐标展开:把圆环问题变成矩形问题
这是整套算法里最值得讲的一段。瓶口是一个圆环,直接在圆环内找边缘缺陷,你需要处理圆弧弯曲。如果把圆环沿径向切开,铺成一个矩形,那么瓶口外边缘变成一条水平直线,缺陷就是这条直线上的凹陷或凸起,检测逻辑瞬间简单。这个思路在工业视觉里用在很多环形工件检测上,不只是瓶口。
展开公式:对圆环内的每个角度 theta,沿半径 r 从内半径到外半径采样像素,写入展开图的每一行。采样时要用双线性插值,避免网格锯齿。下面是实现。
cv::Mat polarUnwrap(const cv::Mat& src, const RoiInfo& roi, int rings = 64, int sectors = 360) { cv::Mat unwrapped(rings, sectors, CV_8UC1); cv::Mat mapX(sectors, rings, CV_32FC1), mapY(sectors, rings, CV_32FC1); double rMin = roi.radius * 0.7; double rMax = roi.radius; for (int r = 0; r < rings; ++r) { double radius = rMin + (rMax - rMin) * r / rings; for (int s = 0; s < sectors; ++s) { double theta = 2 * CV_PI * s / sectors; mapX.at<float>(s, r) = roi.center.x + radius * cos(theta); mapY.at<float>(s, r) = roi.center.y + radius * sin(theta); } } cv::Mat dst; cv::remap(src, dst, mapX, mapY, cv::INTER_LINEAR); cv::transpose(dst, unwrapped); return unwrapped; }这里mapX和mapY的第一维设成 sectors,第二维设成 rings。cv::remap会按照 mapX[行][列] 去原图取像素,生成一个 sectors × rings 的临时矩阵,再通过transpose转成 rings × sectors 的展开图。你可能会问为什么不用 direct 写法:直接写双重循环unwrapped.at<uchar>(r, s) = src.at<uchar>(...)也可以,但速度慢很多,而且还要处理边界越界。remap一次调用就完成了所有像素的采样和插值,性能高出几个数量级。
参数rings=64表示采样 64 行,sectors=360表示 360 列。对于分辨率为 640×480 的瓶口,64 行足够表达边缘细节,太多会增加计算量。如果你想让展开图保留更多缺陷细节,可以考虑 rings=128,但耗时约翻倍。我一般先设 64,观察展开图边缘是否平滑,如果出现锯齿,再增加。另一个关键点是内半径rMin的选择。我取了外半径的 0.7 倍,因为瓶口内侧不是检测区域,太靠近中心会把瓶底部分图像混进来,干扰边缘定位。
展开图的意义:正常瓶口的展开图是一幅从上到下由暗到亮或由亮到暗的渐变图,边缘线近似水平。缺陷会破坏这条水平线:崩边会造成边缘线局部下凹(缺口),裂纹会造成垂直方向的暗条纹。此时你只需要在展开图上做垂直投影,寻找边缘线的跳变点。你还可以把展开图保存下来,用普通图像处理软件查看,非常直观。
4.3 边缘提取与缺陷判定:投影和连通域组合,误判率最低
展开之后,先对展开图做高斯模糊,再用cv::Canny提取边缘。Canny 的双阈值设置很关键,太低全是噪声,太高把小的崩边也滤掉。我现场的经验是低阈值为 50,高阈值为 150,配合 3×3 的 Sobel 核,适合大部分玻璃瓶口。当然这个值要按实际图片批量标定。标定的方法是:准备一批无缺陷图,调整阈值直到 Canny 结果里没有明显的边缘碎片;再准备有缺陷图,调整阈值直到缺陷边缘都被保留。两个条件同时满足的区间就是可用区间。
然后我们关注边缘线:正常瓶口边缘在展开图中是一条接近水平的强边缘。我采用“行灰度投影 + 峰值检测”定位边缘线位置,再用与理想边缘的差分来寻找缺陷。这里有一个要点:不要直接用 Canny 的每个像素去判缺陷,因为 Canny 边缘是二值的,噪声会形成很多细碎的边缘碎片。先统计每一列第一个前景像素的行位置,形成一条曲线,然后对曲线做中值滤波得到理想边缘,两者相减即可凸显异常。
// 4.3 findDefects: 在展开图上找边缘断点和凸起 int findDefects(const cv::Mat& unwrapped, cv::Mat& viz) { cv::Mat gray; if (unwrapped.channels() == 3) cv::cvtColor(unwrapped, gray, cv::COLOR_BGR2GRAY); else gray = unwrapped.clone(); cv::Mat blur; cv::GaussianBlur(gray, blur, cv::Size(3, 3), 0); cv::Mat edges; cv::Canny(blur, edges, 50, 150); // 对每一列统计边缘点的行位置,得到边缘轮廓线 std::vector<int> edgePos(edges.cols, -1); for (int c = 0; c < edges.cols; ++c) { for (int r = 0; r < edges.rows; ++r) { if (edges.at<uchar>(r, c)) { edgePos[c] = r; break; } } } // 用中值滤波平滑,得到理想边缘 std::vector<int> smooth = edgePos; // 简单滑动窗口取中值 for (int c = 20; c < edgePos.size() - 20; ++c) { std::vector<int> win(edgePos.begin() + c - 20, edgePos.begin() + c + 21); std::nth_element(win.begin(), win.begin() + win.size()/2, win.end()); smooth[c] = win[win.size()/2]; } int defectCount = 0; for (int c = 0; c < edgePos.size(); ++c) { if (edgePos[c] > 0 && std::abs(edgePos[c] - smooth[c]) > 5) { defectCount++; } } return defectCount; }这里判定缺陷的阈值“5”是经验值,对应在展开图像素行上的偏移量。现场可以选择把展开图放大,也可以按瓶口实际尺寸换算成毫米级。比如 64 行对应环带宽度为 2mm,那么一行就是 0.031mm,偏移 5 行代表 0.15mm 的边缘跳动,这个精度已经超过人工目检。但你千万别直接照抄这个 5,要看自己的展开图尺寸和瓶口大小。更科学的做法是测 50 个合格品的边缘偏移分布,取均值 ±6 倍标准差作为阈值,这样能把设备的正常震动和机械误差都包含进去。
仅靠边缘位置跳变不能覆盖所有缺陷类型。脏污和气泡往往不改变边缘位置,但会改变展开图局部灰度均值。所以我会再加一个“区域差分”判据:用展开图的灰度值减去该行中值,差值超过阈值的像素块标记为缺陷。两种判据结合,可以覆盖边缘型和区域型缺陷。条件允许时,你把展开图直接保存下来,用图像标注软件看一遍,确认哪些是真实缺陷,哪些是反光。这个“证据链”很重要,你拿着缺陷图给客户看,比任何测试报告都有说服力。
5. 避坑指南:环境依赖、图像时序和判据漂移的四个排雷点
5.1 Debug 和 Release 库混用,程序启动即崩
现象:编译通过,程序一启动就弹出“应用程序无法正常启动 0xc000007b”,或者运行到 cv::Mat 创建时崩溃。
原因:链接了不匹配的 OpenCV 库。常见于使用 Visual Studio 编译时,默认是 Debug 配置,但手动添加的 LIBS 却是 release 版 opencv_world,或者反过来。OpenCV 的 debug 库带 “d” 后缀,release 不带,两者二进制不兼容。更隐蔽的情况是系统 PATH 环境变量里同时存在多个 OpenCV 的 bin 目录,比如你之前装过旧版本,Qt 程序运行时优先加载了旧版 dll,即使编译链接的是新版本库。这就是典型的玄学报错。
解决:在 pro 文件严格按照CONFIG(debug, debug|release)区分库名,上面 3.1 节已经写过。如果是 CMake,要分别设置Opencv_DIR并利用自动变量。另一个技巧是,用进程管理器查看 exe 加载的 dll 路径,确认是不是来自你预期的 OpenCV bin 目录。也可以在程序启动时打印cv::getBuildInformation(),看版本号和你编译时是否一致。我养成一个习惯:每台新机器部署前,先把 PATH 清理一遍,只保留一套 OpenCV 的 bin。
5.2 QImage 显示颜色错乱,看起来像考试卷子
现象:在 QT 界面上显示的图像,天空变红,草地变紫,总之颜色不对。
原因:OpenCV 的imread读进来是 BGR 顺序,QImage 期望 RGB。直接QImage(data, w, h, step, Format_RGB888)没有做通道转换,就把 B 和 R 颠倒了。这个问题在只有灰度图像时不存在,但一旦你为了展示检测结果叠加彩色标记,或者调试时显示彩色原图,就会暴露。
解决:用cv::cvtColor(mat, mat, cv::COLOR_BGR2RGB)先转成 RGB,再构造 QImage。或者构造 QImage 后调用rgbSwapped()。注意如果是灰度图,不需要任何转换,直接用Format_Grayscale8即可。我还有一个小技巧:在 QImage 的构造函数里使用mat.step作为 bytesPerLine,很多显示倾斜的问题都是因为这个参数没传对。另外,不要用 QImage 直接引用 Mat 数据后不调用 copy,否则 Mat 一旦重新分配内存,QImage 就会悬空,产生随机崩溃。
5.3 霍夫圆参数一改就翻车,换了个瓶子全筛废
现象:用cv::HoughCircles检测瓶口圆,在 A 瓶型上能画出完美的圆,切到 B 瓶型,圆心跳来跳去,检测结果一片 NG。
原因:HoughCircles有两个关键参数:累计器阈值accThreshold和圆心距minDist。这两个参数对边缘密度非常敏感。瓶口直径变了,边缘上的点数变了,累计器阈值却不变,导致检测不到圆或者误检。而且霍夫圆本身在低纹理区域会有随机性,同一张图前后两次运行,圆心结果可能有 1-2 个像素的抖动,这在高速产线上会被放大为误检。这就是所谓的“玄学调参”,项目里最耗时间。
解决:抛弃“每帧独立霍夫圆检测”的思路,改用“先粗定位,再局部精修”。我的做法是:由于机械工位固定,瓶口在实际图像中的位置变化很小。用一帧标定出中心和外半径,后续帧在这个中心附近做一个局部 ROI,然后在这个 ROI 内做边缘查找,更新半径。这样即使瓶口有小幅移动,也不会因为霍夫圆的随机性造成检测结果抖动。源码里如果没有这个机制,我建议你在locateRoi里加入“上一帧位置 + 当前帧搜索范围”的状态量。具体实现就是用上一次的圆心为中心,取一个 40×40 的窗口,再在窗口内做灰度重心法求精确圆心。这个方法比霍夫圆稳定得多。
5.4 光照一变,阈值就失效,甚至把合格瓶判成缺陷
现象:早上调试好的阈值,中午阳光透过窗户照到产线上,误检率从 0.1% 飙升到 10%。
原因:固定阈值只适应固定光照。瓶口是玻璃,对光照变化极其敏感。OTSU 虽然是自动阈值,但它在光照渐变时大概率会把背景和高光混在一起。更麻烦的是,产线上相机如果用的是自动曝光,亮度会自动调整以维持整体灰度均值,这会让严重缺陷的对比度被自动曝光“掩盖”掉。所以工业相机一般建议固定曝光时间。
解决:第一选择是加遮光罩,从根本上稳定光照,这是工业现场的第一原则,比任何算法都重要。第二选择是算法自适应:在定位阶段用 OTSU,在判定阶段用“相对阈值”,比如取展开图全局灰度均值的一定偏移量作为阈值,而不是写死。相对阈值对光照变化免疫,但要注意不能让缺陷本身把均值拉走。如果缺陷面积占整个展开图的比例很小,比如小于 1%,这个偏移量方法是稳定的。另外,我建议在相机 SDK 里把曝光和增益设为固定值,并且每次开机自动重新加载。如果现场环境光实在无法遮挡,可以考虑加一个传感器检测环境照度,超过一定范围时报警提示。
6. 进阶技巧:用动态阈值和形态学重构提高现场适应力
前面判据里我用了一个固定阈值 5,这在 demo 里够用,但现场不够。我这里提供一个更稳的方案:动态阈值 + 形态学重构。
动态阈值不基于固定偏移量,而是基于整个展开图行的统计分布。对每一行取灰度中值,然后用中值减去一个常数得到该行的缺陷检测阈值。由于中值对缺陷不敏感(除非缺陷占该行比例超过 50%,基本不可能),这个阈值能跟随光照和玻璃厚度缓慢变化。
// 6.1 动态阈值: 每一行计算均值,用均值+偏移作为上下限 for (int r = 0; r < unwrapped.rows; ++r) { cv::Mat row = unwrapped.row(r); cv::Scalar mean = cv::mean(row); // 用 mean 代替中值,粗略也够用 double low = mean[0] - 20; // 缺陷通常是低灰度区域 double high = mean[0] + 20; // 如果是亮缺陷,反过来 // 这里可以将 row 中超过 [low, high] 的像素标记为候选 // 实际用 cv::inRange 即可 }这里用mean代替中值是有妥协的,因为mean计算快。实际应用中值需要用cv::sort或nth_element,在整幅展开图上逐行计算仍然很快,一毫秒内可以完成。我之所以在代码里先用 mean 占位,是为了让你看清楚思路:缺陷像素是那些偏离正常行分布的异常值。
更重要的是对展开图应用形态学重构:先做一次闭运算,把细小的噪声填充掉,再与原图做差,剩下的才是真正的缺陷区域。闭运算的核大小要选为 3×3,太大把细小的崩边也填平了。差的绝对值超过动态阈值的像素组成连通域,再用cv::connectedComponentsWithStats按面积过滤。面积阈值一般设为几十到几百像素,取决于展开图的放大比例。这一步能滤掉单个像素的椒盐噪声,也能把多个断裂的裂纹拼接成一个完整缺陷,避免一个裂纹被数成十个缺陷。
我实际做过的产线上,这种“动态阈值 + 差影 + 连通域面积”组合,在更换瓶型时只需要重新标定一帧,不到十分钟。比固定阈值方案省去大量改参数时间,那就是把“调参玄学”变成了“先验知识”。验证一个检测系统是否稳定,建议准备三组图:一组无缺陷瓶,一组人工制造缺陷瓶,一组现场随机采集。分别统计误检率和漏检率。漏检率为零,误检率控制在 1% 以内,才敢上产线。
最后分享一个习惯:每改一个参数,都把展开图、边缘图、判定结果保存成文件,文件名带上参数和时间。遇到客户说“昨天还好好的今天全 NG”,翻出历史文件对比,几分钟就能定位是光照漂移还是参数被人改了。我在项目里加了一个日志模块,把所有保存的图片按日期归档,同时导出一个 CSV 表格,包含每帧的结果、缺陷类型、缺陷面积和耗时,方便做故障回放。这个习惯让我少加了无数次班。希望帮到你。
本文还有配套的精品资源,点击获取