C+++OpenCV+Qt实现啤酒瓶口缺陷检测,从原理到落地

发布时间:2026/10/18 20:12:59
C+++OpenCV+Qt实现啤酒瓶口缺陷检测,从原理到落地 简介基于OpenCV与QT实现的啤酒瓶口缺陷检测C源码面向机器视觉初学者与工业质检应用开发者可用于识别瓶口外环破损、内环破损、缺口等常见缺陷。压缩包共29个文件以6个cpp核心源码与14张png测试图像为主另含头文件、UI设计文件、Qt工程配置等整体仅4.73MB代码与样本分离便于直接查阅。已有216人学习下载具备一定的参考价值。源码完整覆盖灰度化、高斯滤波、自适应阈值、形态学操作、连通区域查找、最大轮廓提取、面积周长与圆形度计算、质心定位及缺陷判断等步骤并集成QT界面展示检测结果方便读者快速跑通流程、比对不同瓶口样本的效果。1. 一瓶啤酒的瓶口为什么值得你用 C 重写一遍检测逻辑在啤酒灌装线上瓶口缺陷是典型的“不致命但很致命”的质量问题崩边会让瓶盖密封不严裂纹在加压时漏气脏污直接影响灌装卫生。很多小厂还在靠老师傅用眼睛对着灯光看看两小时就疲劳疲劳就漏检漏检的酒瓶到了消费者手里就成了投诉和退货。我接过一个现场项目一条线每分钟过 600 个瓶子人工复查的速度根本跟不上而且不同的人判定的松紧程度还不一样。基于OpenCVQT实现的啤酒瓶口缺陷检测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.librelease和 opencv_world450d.libdebug。如果 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 c11 # 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 转换不要直接在界面上画 MatQT 的 QPixmap 不能直接兼容 cv::Mat因为两者的内存布局不同。直接做一次拷贝或转换是必须的。最常见的坑是颜色通道顺序OpenCV 是 BGRQImage 通常是 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::vectorstd::vectorcv::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.atfloat(s, r) roi.center.x radius * cos(theta); mapY.atfloat(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.atuchar(r, s) src.atuchar(...)也可以但速度慢很多而且还要处理边界越界。remap一次调用就完成了所有像素的采样和插值性能高出几个数量级。参数rings64表示采样 64 行sectors360表示 360 列。对于分辨率为 640×480 的瓶口64 行足够表达边缘细节太多会增加计算量。如果你想让展开图保留更多缺陷细节可以考虑 rings128但耗时约翻倍。我一般先设 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::vectorint edgePos(edges.cols, -1); for (int c 0; c edges.cols; c) { for (int r 0; r edges.rows; r) { if (edges.atuchar(r, c)) { edgePos[c] r; break; } } } // 用中值滤波平滑得到理想边缘 std::vectorint smooth edgePos; // 简单滑动窗口取中值 for (int c 20; c edgePos.size() - 20; c) { std::vectorint 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 表格包含每帧的结果、缺陷类型、缺陷面积和耗时方便做故障回放。这个习惯让我少加了无数次班。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询