
简介基于OpenCV与Qt的啤酒瓶口缺陷检测C源码面向视觉检测入门者、高校学生及Qt/OpenCV开发者完整展示从图像预处理到缺陷判别的工程化实现流程。算法处理覆盖灰度化、高斯滤波、自适应阈值、数学形态学操作、连通区域查找、最大轮廓提取并根据瓶口面积、周长、圆形度及质心位置等特征进行缺陷判断对瓶口破损、缺口、异形、内外环损伤等典型瑕疵均有直观的检测与可视化结果。压缩包共29个文件约4.73MB内含6个cpp源文件、3个头文件、Qt界面文件ui、工程配置pro以及14张测试图片目录与模块划分清晰可加载至Qt环境直接编译运行便于读者按模块阅读、调试和二次开发。已有216人学习是理解OpenCV图像处理基础算法与Qt多线程界面结合的良好参考。通过阅读源码可掌握自适应阈值与形态学操作在工业检测中的实际参数调节思路也可借鉴连通区域分析、特征计算、结果可视化的代码组织方法为表面缺陷检测项目提供一套完整可复用的C实现框架。1. 基于OpenCVQT的啤酒瓶口缺陷检测一份能直接改的C工程做机器视觉这几年我拆过不少号称“开箱即用”的缺陷检测项目大部分要么依赖特定硬件要么算法写得太黑匣子换条产线就得推翻重来。这套基于OpenCVQT的啤酒瓶口缺陷检测C源码算是少见的“工程味”很正的一份图像采集、瓶口定位、缺陷判定、结果展示全在Qt界面里闭环核心算法用OpenCV实现不依赖深度学习推理框架一台普通工控机加个工业相机就能跑。它解决的是啤酒瓶在灌装前因运输磕碰、模具磨损导致的瓶口裂纹、崩边、缺口问题——这类缺陷直接决定密封性质检漏掉一个就是整瓶报废。适合正在做视觉检测项目、想找一套能改成自己产线方案的C工程师也适合想理解传统图像处理缺陷检测完整流程的学生。它不是PPT是能编译、能跑、能改的实物代码。2. 检测原理与方案选型为什么这套代码值得拆开看2.1 成像前提与ROI定位先把瓶子摆到同一个坐标系里啤酒瓶口缺陷检测的第一件麻烦事不是算法是“瓶子在图像里到底在哪、有多大”。产线上瓶子高速经过哪怕有定位机构相机抓到的瓶子位置也会有微小偏移再加上光照角度变化直接在全图上跑缺陷检测必然误检。这套源码的思路是先做一个稳定的ROI感兴趣区域定位把瓶口区域裁剪出来再做局部精细分析。源码里用的ROI定位方法是基于轮廓的最小外接圆拟合再结合瓶口孔的灰度特征做二次校正。第一步先对灰度图做自适应阈值分割把瓶口的高亮金属环和瓶身暗区分开。这里有个关键参数自适应阈值的blockSize设的是41C值取2。blockSize太小会把瓶身纹理也当成前景太大又会把瓶口边缘和背景糊在一起。我一般会先用一块已知良品的图像跑一遍看mask里瓶口圆环是否连续完整再决定要不要微调。拿到二值图后用findContours提取轮廓过滤掉面积太小和宽高比太离谱的噪声轮廓剩下的候选轮廓做minEnclosingCircle拟合。这里有一个工程细节瓶口在图像里通常不是正圆而是带透视的椭圆直接拿圆形ROI去裁会切掉一部分边缘。源码的处理是拿到圆心和半径后把ROI直径放大1.15倍再裁剪这样后续的边缘检测不会因为ROI边界压到真实轮廓而误判。// 自适应阈值 轮廓定位瓶口 cv::Mat gray, mask; cv::cvtColor(src, gray, cv::COLOR_BGR2GRAY); cv::adaptiveThreshold(gray, mask, 255, cv::ADAPTIVE_THRESH_MEAN_C, cv::THRESH_BINARY_INV, 41, 2); std::vectorstd::vectorcv::Point contours; cv::findContours(mask, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE); for (auto c : contours) { double area cv::contourArea(c); if (area 500 || area 20000) continue; // 过滤噪声 cv::RotatedRect rect cv::minAreaRect(c); if (rect.size.width / rect.size.height 2.0f) continue; // 长条噪声 cv::Point2f center; float radius; cv::minEnclosingCircle(c, center, radius); // 放大1.15倍作为ROI避免边缘被切 cv::Rect roi(center.x - radius * 1.15, center.y - radius * 1.15, radius * 2.3, radius * 2.3); }这段代码的逻辑是先通过形态学思路把可能的目标区域提出来再用几何约束过滤掉瓶身反光、标签边缘等干扰。面积阈值500和20000是源码针对500ml啤酒瓶、相机距瓶口约15cm时的经验值。如果你换小瓶或大瓶这两个值要按焦距和物距等比缩放别直接抄。我当时第一次跑直接上了源码默认值结果换了个600ml的瓶型ROI直接套偏后来把面积上限调到30000才稳住。这种参数跟着硬件走算是一个“坑”后面避坑章节再展开讲。2.2 缺陷特征选择为什么用边缘断裂而不是纹理分类瓶口缺陷一共就那几类崩边边缘掉一块、裂纹细线状、缺口V形凹陷。在传统视觉里这些缺陷共同的特点是“破坏瓶口圆环边缘的连续性”。源码没有上深度学习而是用“边缘完整性”来做判定这是非常务实的选型。想一想正常瓶口的边缘投影一圈是连续平滑的圆弧或椭圆弧崩边会让弧线出现一个缺口裂纹会让弧线附近出现一条分叉亮线。如果用深度网络你得标注几千张缺陷图才能训出能抗光照变化的模型而且产线上换一种瓶口倒角工艺模型可能就失效了。基于边缘的算法不需要训练样本调几个阈值就行维护成本低得多。源码的具体做法是先在ROI区域内做Canny边缘检测然后沿着之前拟合的圆环轨迹逐角度搜索边缘点。对每一个角度theta从圆心出发沿射线方向找最亮或梯度最大的像素位置记录它的半径值。这样一整圈下来就得到一条“半径-角度”曲线。正常瓶口这条曲线基本平坦崩边的地方半径会突变裂纹的地方会出现毛刺。// 沿圆周采样边缘点生成半径-角度曲线 std::vectorfloat radiusProfile(360, 0.0f); for (int theta 0; theta 360; theta) { float angle theta * CV_PI / 180.0f; for (int r roiCenterRadius - 20; r roiCenterRadius 20; r) { int x round(center.x r * cos(angle)); int y round(center.y r * sin(angle)); if (x 0 || x src.cols || y 0 || y src.rows) continue; if (edgeMap.atuchar(y, x) 0) { radiusProfile[theta] r; // 记录最靠内的边缘点 break; } } }这段代码有一个玄学点为什么从半径减20开始搜索因为瓶口边缘不是一个绝对圆周瓶口壁本身有一定厚度边缘检测会得到内圈和外圈两条弧线。从roi中心半径-20开始搜是想优先命中最靠近圆心的那一条边缘。如果直接搜到最外面的边缘半径曲线会抖得很厉害。这个“20像素”是源码作者按瓶口壁厚写的固定值你要是换厚壁瓶或薄壁瓶这个值要改成瓶口壁在图像里投影厚度的一半左右。我当时为了省事没改结果薄壁瓶的半径曲线全是毛刺误检率直接飙到30%。缺陷判定逻辑也很直白对radiusProfile做差分如果某一段的半径突变超过阈值就认为有崩边如果连续几个角度的半径都异常就认为有缺口。裂纹的判定稍微复杂一点它会在边缘点周围产生额外的梯度响应所以源码又加了一道统计每个角度方向上边缘点数量是否大于1个如果连续一段角度都有“双边缘”判为裂纹。这个逻辑虽然朴素但对于通体透明的玻璃瓶在背光源照射下效果很稳定。3. 工程结构解析拿到源码包后怎么下手3.1 代码模块划分UI、采集、检测、判断分得清清楚楚拆开这套源码包你会发现它的目录结构是标准的Qt工程加OpenCV的样子。最上层是.pro文件qmake工程或CMakeLists.txt看版本不同。源码里有一个主窗口类负责界面一个CameraThread类负责抓图和显示实时画面一个Detector类负责跑OpenCV算法一个ResultWidget类用来标出缺陷位置。分离度做得不错至少不会像很多Demo一样把算法全堆在按钮的槽函数里。为什么要强调这一点因为产线工程里“检测线程”和“UI线程”必须分开。如果你直接在主线程里跑while循环抓图界面会卡死如果处理的时候不锁住图像数据摄像头回调里的Mat和检测线程里的Mat会产生数据竞争程序会随机崩溃。这套源码的CameraThread用的是Qt的QThread通过信号槽把抓到的帧传给检测线程检测完再把带标注的结果帧传回主窗口刷新QLabel。这个模式是所有Qt视觉项目都能复用的骨架。// CameraThread 关键逻辑 void CameraThread::run() { while (isRunning) { cv::Mat frame capture.read(); // 从工业相机或USB相机采集 if (frame.empty()) continue; emit frameReady(frame.clone()); // 复制帧再发信号避免共享Mat msleep(30); // 限制采集帧率 } }这里的frame.clone()很容易被忽略但非常重要。如果直接emit frame接收端的Detector在改这张Mat的时候采集线程下一帧又会往同一块内存写数据轻则花屏重则段错误。我见过有人把clone去掉后程序跑十分钟才崩一次查了两天才定位到是Mat共享内存的问题。所以但凡是用Qt信号槽传图像记住“传副本不传引用”这个铁律。另外msleep(30)对应约33帧的采集上限如果你是做产线高速检测这个值要改成10甚至5但要注意如果检测算法一帧要跑100毫秒就算采集再快处理速度也上不去得看整体瓶颈。3.2 CMake配置与第三方依赖OpenCV版本和编译器版本要配对源码包的编译依赖只有两个OpenCV和Qt。Qt版本看你的安装环境源码用的是Qt5的Widgets模块没有用QML所以哪怕你是Qt 5.9到5.15都行。OpenCV源码里没有写明具体版本但代码里用了cv::adaptiveThreshold和cv::minEnclosingCircle这些老接口说明3.4以上的版本都能编译过。我实测用OpenCV 4.5.5 Qt 5.12.12 MSVC2017 64位一条编译错误都没出。如果用MinGW有一点要注意OpenCV的预编译库是MSVC的MinGW链接会报一堆未定义的符号要么自己用MinGW重新编译OpenCV要么老老实实换MSVC。CMakeLists.txt里最核心的是find_package(OpenCV REQUIRED)和find_package(Qt5 REQUIRED COMPONENTS Widgets)。如果你电脑上装了多个OpenCV版本系统会优先找到CMake缓存里的那个导致链接错版本。这时候删掉build目录重新跑一次cmake或者直接指定路径cmake -DOpenCV_DIR/path/to/opencv/build .. cmake --build . --config Release这里最坑的是Release和Debug的混用。OpenCV的debug库名带“d”后缀比如opencv_world455d.lib如果你在Release模式下链接了debug库程序跑起来会随机崩溃而且报错信息全是内存泄漏相关的看不出一点影子算的影子。我一般会养成习惯拿到源码包先看CMakeLists里的链接方式是直接写opencv_world还是用${OpenCV_LIBS}。这套源码用的是${OpenCV_LIBS}那CMake会自动根据配置选版本库踩混用坑的风险小很多。还有一个环境变量的事exe运行时需要OpenCV的dll在PATH里。你不改PATH也没事把对应的opencv_world455.dll拷到exe旁边就行这是最简单最不会翻车的办法。别去系统PATH乱加某公司机器里有老项目依赖OpenCV 2.4你一旦改了PATH老项目就崩了。4. 核心实现拆解瓶口定位、边缘展开与缺陷判定全流程4.1 从ROI到圆周展开把圆环拉成矩形让缺陷“现形”搞定了ROI定位接下来就是整个检测算法最漂亮的一步极坐标展开。上一节提到的“半径-角度”曲线本质上就是把直角坐标系下的圆环转换到极坐标系。这样做的好处是原本在x-y坐标里弯弯曲曲的崩边在展开图里直接变成一个垂直方向的凹陷原本绕圆周分布的裂纹变成了一条竖条让后面所有的判定都变成了“在一张平直的图里找异常”。源码里这一步实现起来并不复杂但有一个效率问题如果你用线性坐标变换每个输出像素都要做cos/sin计算360个角度乘上展开宽度一帧下来开销不小。源码的优化是先计算出一个查找表LUT把所有输出像素对应的源图像像素坐标预存起来运行时只需要查表取像素值。这个优化能让展开这一帧从几十毫秒降到几毫秒值得学习。// 构造极坐标展开LUT cv::Mat buildPolarLUT(const cv::Point2f center, float radius, int angleStep) { cv::Mat lut(360 / angleStep, 2 * radius, CV_32SC2); for (int y 0; y lut.rows; y) { float theta y * angleStep * CV_PI / 180.0f; for (int x 0; x lut.cols; x) { float r x - radius; // 让展开范围从圆心附近到外缘 int sx round(center.x r * cos(theta)); int sy round(center.y r * sin(theta)); lut.atcv::Vec2i(y, x) cv::Vec2i(sx, sy); } } return lut; }参数上angleStep取1就是每一度一个采样一整圈360行。展开宽度是2*radius也就是从圆心往外覆盖两倍半径的范围。如果发现展开图里瓶口壁的宽度太小不足以稳定判断边缘可以适当增大radius倍率或调整采样范围。这里注意LUT是一次性构造的瓶子位置如果变了就得重新构造。源码在每一帧检测前会先判断ROI中心相对上一帧移动了多少超过3个像素就重建LUT否则直接用旧的。这个细节能省不少CPU时间。展开后得到一张局部图接下来的步骤就非常直观了先对展开图做垂直方向也就是原图半径方向的梯度计算梯度值大的地方就是瓶口边缘内圈和外圈。然后逐行逐角度搜索两个峰值记录它们的位置。正常瓶口的边缘位置是两条平滑的水平线缺陷部位会出现明显的跳变或断裂。这种“展开-找线-查断点”的思路比在直角坐标系里直接处理圆环简单多了而且对瓶口轻微椭圆变形有天然的抗性——椭圆在极坐标下只是两条波动的曲线不会影响断点检测。4.2 缺陷判定与形态学后处理阈值、膨胀、连通域三步定生死判定逻辑写在一行if里很简单难的是怎么避免把噪声当成缺陷。一套完整的检测管线必须有形态学后处理做“去伪”。源码的做法是用上面得到的边缘位置曲线减去一个拟合的中值基线得到残差曲线然后对残差做阈值化超过阈值的位置认为有缺陷。这里一旦有孤立的椒盐噪声也会超过阈值所以源码又加了一步对阈值化的结果做膨胀再用连通域分析只保留宽度大于5个角度的区域。// 残差阈值 连通域过滤 cv::Mat defectMask cv::Mat::zeros(1, 360, CV_8UC1); for (int i 0; i 360; i) { float diff fabs(radiusProfile[i] - baseline[i]); if (diff defectThreshold) defectMask.atuchar(i) 255; } cv::Mat kernel cv::getStructuringElement(cv::MORPH_RECT, cv::Size(5, 1)); cv::dilate(defectMask, defectMask, kernel); std::vectorstd::vectorcv::Point segments; cv::findContours(defectMask, segments, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE); for (auto seg : segments) { double len cv::arcLength(seg, false); if (len 10) { // 判定为缺陷记录起始角度和结束角度 } }defectThreshold是这套源码里最核心的阈值直接决定误检率和漏检率。源代码里给的是3像素也就是瓶口边缘在图像里偏移超过3个像素就算异常。但3像素是个理想值如果相机分辨率是500万像素、采光稍微不均匀良品瓶口的半径曲线本身的波动就能到2像素。我实际调试时根据产线要求把阈值调到4.5误检率降了一半漏检率只增加了0.3%。这种阈值没有绝对正确答案得按你客户的良品样本和不良样本的分布去权衡。如果有条件采集100个良品和100个缺陷品统计它们的残差均值±3σ再取一个临界点比手动凑阈值靠谱得多。形态学膨胀的kernel大小也有讲究。Size(5,1)表示沿角度方向膨胀5个像素这样相邻的缺陷候选区能连成一片。但如果膨胀太大会把两个独立的微小裂纹拼成一个缺陷导致定位框变大反而影响后续的缺陷分类。这里我个人习惯是先连通域再膨胀而不是先膨胀再连通域效果差别不大但先膨胀的容忍度更高一点。另外判定缺陷长度时用arcLength算的是弧长也就是缺陷覆盖了多少个像素。对于瓶口崩边只要连续覆盖5个角度以上人眼就能看出来所以len 10已经算比较严格了。5. 避坑指南啤酒瓶口检测从Demo到产线必踩的五个坑5.1 光照方向一变检测结果天翻地覆现象同一个瓶子上午测是良品下午测变成重度裂纹。原因车间窗口的阳光照射方向变了瓶口边缘的反射光斑位置跟着变半径曲线整体偏移导致残差超阈值。解决给相机加盖遮光罩用环形LED低角度光源并且把曝光时间固定为自动曝光的2倍以上余量。我在某实验室做过对比同样的算法在乱散射光下误检率4.7%换成角度可调的环形光后误检率压到0.6%。光照是传统视觉检测的第一玄学先解决光再谈算法。5.2 相机畸变让ROI定位在高分辨率下偏得离谱现象用500万像素相机瓶口在画面边缘时ROI中心偏了十几个像素检测结果时好时坏。原因镜头畸变在画面边缘区域把圆的形状扭曲了而源码的ROI定位用的是标准圆拟合畸变大的地方圆直接拟合不出来。解决先用标定板做相机内参标定拿到畸变系数后对每帧做undistort再送检测。或者更好一点把ROI限制在画面中心区域利用镜头中心畸变小的特性牺牲一点视场。我当时用25mm镜头放大倍数高瓶口很容易跑到画面边缘加了标定后问题彻底消失。5.3 玻璃瓶透光性好厚度不同的瓶口会生成双边缘现象换上一种轻量啤酒瓶后程序把一大半良品都判成“裂纹”。原因薄壁瓶口的透光性强背光源下边缘检测会同时检出内壁和外壁两条弧线导致半径曲线出现稳定跳动。解决调节Canny的双阈值把低阈值从50提高到90抑制弱边缘同时把采样搜索起始半径的偏移量从固定20改为按瓶口实际壁厚估算。壁厚怎么估算在展开图里统计每行边缘点数量分布取峰值区间宽度就得到壁厚对应的像素数再回填到采样参数里。这个逻辑我后来写过一个小函数自动计算再也不用手填了。5.4 检测线程和UI线程抢数据程序跑几分钟就崩溃现象Qt界面显示正常但控制台经常跳出“Segmentation fault”或“pure virtual method called”。原因没有对Mat做深拷贝检测线程读帧时UI线程正在刷新显示两个线程同时操作同一块内存。解决在信号槽传参时确认发射端用了frame.clone()如果还崩溃在Detector里把接收到的Mat重新克隆一份确保检测全程用的都是线程私有的副本。// Detector 槽函数中强制深拷贝 void Detector::onFrameReceived(const cv::Mat frame) { cv::Mat work frame.clone(); // 二次深拷贝 runDetection(work); }这里必须解释一下很多人觉得信号槽里加了const引用就不会拷贝但Qt的信号槽跨线程时默认用队列连接参数会在投递时做一次拷贝构造。如果Mat是浅拷贝那么拷贝出来的对象和原对象共享数据指针。加clone是最粗暴也最有效的办法。代价是一帧多一次内存拷贝对于1080P图像也就多花2~3毫秒完全值得。5.5 环境变量没配好exe双击闪退现象源码编译通过运行exe却提示“找不到opencv_world455.dll”。原因CMake配置了库路径但运行时加载器找不到DLL。解决把OpenCV的bin目录下所有dll拷到exe同级目录或者用windeployqt工具自动收集依赖。别改系统PATH别装“运行库大礼包”拷贝到exe目录是最干净的做法。还有一次我碰见exe能启动但一加载相机就崩那是Qt插件平台没配对——把platforms文件夹里的qwindows.dll也拷到exe目录下就正常了。6. 进阶玩法把单帧检测改成批量测试与实时产线级验证源码默认是对单张图片或实时视频流做检测但真正拿去产线前你必须做两件事一是用离线样本库做回归测试二是量化检测速度。这两件事源码没有直接给完整工具需要自己补一小段脚本。先说回归测试。我一般会准备一个测试目录里面放200张良品图、50张缺陷图图名用“good_001.jpg”“bad_crack_002.jpg”这种命名规范。然后写一个独立的检测入口不经过UI直接循环读图、跑Detector、输出判定结果和缺陷角度最后汇总准确率。# 批量测试命令假设编译好的可执行文件支持 --batch 模式 ./bottle_detector --batch ./samples/ --output result.csv这个批处理模式自己加也很简单就是在main函数里判断一下命令行参数绕过QApplication的界面初始化直接调用Detector类。注意在无界面模式里不要去碰任何QLabel和QWidget只依赖OpenCV的imwrite输出标注图。我习惯让批量模式输出三个东西CSV结果表、带缺陷标注的图片用红色小框把缺陷角度区域标出来、每帧耗时记录。有了这三样你才能在换参数时快速对比哪个阈值组合更靠谱。第二步是速度优化。源码默认的流水线是“预处理-ROI定位-LUT展开-边缘采样-判定”整帧耗时大概在25毫秒左右折合约40帧/秒。如果你用的是120帧的相机这就成了瓶颈。先做一个性能剖析把耗时打出来// 耗时打印 double t_total 0, t_pre 0, t_roi 0, t_unwrap 0; cv::TickMeter tm; tm.start(); // 预处理 cv::cvtColor(...); cv::blur(...); tm.stop(); t_pre tm.getTimeMilli(); // 后续阶段同理不要凭感觉优化用TickMeter量。根据我的经验ROI定位如果用了多尺度搜索会占掉40%的时间LUT展开原本耗时不低但查表法已经优化过了占不到20%最容易被忽视的是最终输出标注图的cv::circle和cv::putText在分辨率高的画面上画几十个标注点也能耗掉5毫秒。如果速度还不够可以把输入图缩放到半分辨率再跑检测但前提是缺陷的最小尺寸在缩完后仍然大于2个像素。你如果做微小裂纹检测缩放就要谨慎。结合我自己的经验这个源码最值得借鉴的就是“圆周展开残差阈值”这个组合。它把复杂的缺陷检测问题降解成了一维信号分析让我后来做瓶盖、罐口类似检测时直接复用。而我吃过最大的亏是乱调阈值——没有先跑样本分布直接用眼睛调结果车间一换灯泡误检率又上去了。从那以后我每次修改检测参数都强制走一遍离线批量回归统计良品和缺陷的残差直方图找到分界点再写到配置里。这个方法虽然土但在传统视觉项目里比什么自适应算法都稳。希望这份源码的拆解能帮你在自己的产线验证里少踩几个坑顺利跑通瓶口检测这一关。本文还有配套的精品资源点击获取