
玻璃瓶裂纹检测这件事第一次上手的人几乎都会在同一个地方卡住把相机对准瓶子屏幕上看到的是一片惨白的反光裂纹在哪完全靠猜。我最初也以为是自己光源没选对换了两三种灯还是不理想后来才想明白——透明瓶体的裂纹检测难的不是算法本身而是怎么让裂纹在图像里“显形”以及怎么把它从合缝线、水痕、标签边缘这一堆长得差不多的干扰里摘出来。这篇就围绕机器视觉做玻璃瓶裂纹检测这条主线把成像、Matlab 处理链路、特征判定、节拍优化和源码工程结构完整拆一遍附的这套 Matlab 源码可以直接拿来改参数跑自己的样本。内容偏工程实战适合已经会一点 Matlab 图像处理、但真正落到透明件检测上就心里没底的朋友也适合刚接触视觉检测、想搞明白“为什么这么设计”的入门读者。1. 透明瓶体上的裂纹为什么“看不清”成像难点与误检来源拆解1.1 裂纹的物理特征决定了它不是普通的“边缘”很多人做缺陷检测的第一反应是上 Canny 边缘检测因为教程里都是这么写的。放到玻璃瓶上这个思路会立刻翻车。原因在于玻璃的成像特性和金属、塑料完全不同可见光穿过玻璃时大部分直接透射只有遇到裂纹这种断层结构才会发生散射。也就是说在普通明场照明下裂纹不仅不暗反而可能比瓶体还亮一点点同时又和瓶身弧度带来的高光混在一起边缘检测算子根本分不清哪条边是裂纹、哪条边是瓶壁轮廓。从物理上看裂纹有两个可以利用的性质。第一是尺度小工业上判定需要拦截的裂纹宽度通常在几十微米量级长度从零点几毫米到几毫米不等在图像上表现为一条细长的、宽度只有两三个像素的线状结构。第二是方向随机、边界不规则裂纹沿着应力方向扩展走的是锯齿状路径不像模具合缝线那样笔直。这两点决定了算法路线应该往“提取细小线状亮/暗结构”上走而不是往“找物体轮廓”上走。我一般把这个问题拆成三层来看成像层要保证裂纹与背景有足够的灰度差处理层要能把这种差放大成可分割的二值区域判定层要能用几何特征把真裂纹和仿冒者区分开。三层里任何一层没做好后面的努力都是白费而大多数调参失败其实卡在第一层——图里根本没有足够的信噪比算法再花哨也救不回来。1.2 四类最常见的误检源真正跑起来之后你会发现漏检反而不是最头疼的误检业内叫“过杀”才是。我在实际调试里遇到最多的是下面这几类几乎每一种都值得单独做一条规则去压制。干扰类型图像表现与真裂纹的差异常用压制手段模具合缝线笔直的纵向亮线位置固定位置在瓶身圆周上固定角度方向几乎不偏位置掩膜屏蔽或旋转瓶体后多帧比对表面划痕细长亮线方向随机宽度略大、边缘更平滑、对比度偏弱提高顶帽响应阈值 长宽比组合判定水痕/油污不规则块状或弧形亮区面积大、长宽比接近 1、边界圆润面积上限 占空比Extent过滤标签与印刷边缘强对比的边缘线灰度跳变剧烈、位于固定区域ROI 裁剪检测区限定在瓶肩/瓶底光滑段合缝线是最麻烦的一个。它天然满足“细长、方向一致、对比度强”这三个条件用几何特征几乎打不掉。后来我采用的思路是合缝线在每个瓶上的角位置是固定的只要在标定时把它所在的列范围记录下来做一张掩膜图检测前先把它“涂掉”就行。前提是你的瓶子在夹具里的定位重复精度够好一般用机械定位加一个气缸顶紧就能做到正负零点几毫米。如果定位精度不够就得靠瓶体旋转配合多工位图像做差分了那是另一套更重的方案。提示不建议用“面积小于多少就忽略”这种单一条件去过滤。裂纹本身就很小一刀切容易把真缺陷一起砍掉。多条弱条件组合打分的误检抑制效果通常好于一条强条件。2. 光源与相机选型把裂纹“打”到算法能吃的位置2.1 暗场照明为什么是首选如果只能记住一句话那就是裂纹检测优先考虑暗场照明。原理不复杂用低角度环形光或者从侧面打光让绝大部分光线以掠射角掠过瓶壁而不进入镜头相机视野里背景是暗的一旦表面有裂纹这种微观起伏光线在那里发生散射一部分散射光进入镜头裂纹就以亮线的形式跳出来。这种“暗背景亮缺陷”的模式对比度比明场高出好几倍图像信噪比一好后面 Matlaab 里的阈值分割就不用那么极限。实际配置上我一般会用低角度环形光配一个漫射罩把直射的杂光打散避免瓶身弧面产生大块高光。对于需要同时看清楚瓶底和瓶肩的场景可以用分区布光加多相机每个相机只管一个视场。另外偏振片值得一试在镜头前和光源前各加一片交叉偏振可以压掉一部分表面反射对某些镀膜瓶或者带雾面的瓶型效果明显。有一点经常被忽略——瓶体是曲面的。同一个视场里中心区域和边缘区域的入射角差异很大导致同一条裂纹在图像中心的响应可能只有边缘的三分之一。解决办法要么缩短工作距离、缩小视野要么在预处理阶段做一次照度校正用一张标准白板图做背景归一化。这个步骤在源码里对应的是“背景模板减除”成本很低收益很高。2.2 分辨率与节拍的反向推算选相机不能凭感觉得从“最小待检缺陷”倒推。举个例子假定要检出的裂纹宽度是 0.08 mm检测视野宽度取 80 mm涵盖瓶身主要检测面那么需要的横向像素数至少是 80 / 0.08 1000但工程上通常要求一个特征至少占据 2 到 3 个像素才稳定可分割所以实际需要 2000 到 3000 像素。这就落到 500 万像素级约 2448×2048的相机上此时像素当量约为 0.033 mm/pixel一条 0.08 mm 宽的裂纹在图上约 2.4 个像素勉强够用但不算富余如果再考虑镜头畸变和亚像素误差建议把视野再收紧或者上更高分辨率的机型。节拍是另一半约束。假设某条产线每分钟过 500 个瓶子也就是每 100 ms 一个这 100 ms 里要完成触发、曝光、传输、处理、判定、输出信号。曝光通常 2 到 5 ms千兆网传一张 500 万像素的 8 位灰度图大概 20 到 30 ms留给 Matlab 算法的就只剩 50 ms 左右。这个预算相当紧直接决定了后面代码不能写得太“教科书”。把这两个约束放在一起看会发现很多选型其实是互相牵制的想提高分辨率就得牺牲帧率或者换更贵的接口想缩短处理时间就得限制 ROI 面积。我一般的做法是先按缺陷尺寸定分辨率再用 ROI 裁剪把有效处理面积压到整幅图的 30% 以内这样即使算法单帧跑 40 ms 也还有余量。源码里的cfg.roi就是干这个用的别嫌它简单产线上这一步能省掉一半时间。3. Matlab 图像处理链路的逐环节拆解3.1 预处理顶帽/黑帽比直接 Canny 更可靠拿到灰度图之后第一步不是边缘检测而是把小尺度结构从大尺度背景里分离出来。形态学顶帽变换imtophat正好干这件事用原图减去开运算结果保留比结构元小的亮细节把大面积的亮度渐变全部抹掉。裂纹在暗场图里是亮线用白顶帽就很合适如果某些瓶型上裂纹表现为暗线就换成黑帽imbothat逻辑对称。结构元的尺寸是有讲究的不能瞎填。它必须比裂纹宽度大、比你要抑制的背景结构小。裂纹宽度 2 到 3 个像素那结构元半径取 5 到 8 个像素比较合适取太小了细节提不出来取太大了会把瓶壁的亮度起伏一起当成细节保留。我在源码里把它做成cfg.topHatRadius参数默认 6调试时按瓶型微调。顶帽之后跟着高斯滤波作用是压掉传感器噪声和表面的细微麻点。这里有个小技巧imgaussfilt的 sigma 不要超过 1.5因为你刚刚费力提取出来的就是宽度两三个像素的细线滤波太狠会把它糊掉。我吃过这个亏一开始用 sigma3 觉得图看起来干净多了结果裂纹的峰值响应直接掉了一半阈值怎么调都检不全。增强之后才是二值化。全局 Otsugraythresh在光照均匀时够用但对曲面瓶体往往不稳定因为不同区域的背景亮度差异大。更稳的做法有两种一是用固定阈值前提是暗场照明很稳定、相机增益锁死我倾向于这种因为可预测二是用adaptthresh做局部自适应阈值鲁棒性好一点但计算量上去了。源码默认走固定阈值乘以一个缩放系数cfg.threshScale方便你在不同批次样本上快速找平衡点。3.2 二值与形态学让断裂的裂纹连成一条阈值分割出来的二值图通常不理想——裂纹会因为局部反光不均而断成好几截同时背景里冒出一堆零散噪点。这时候形态学的作用就体现出来了但顺序很关键先开运算去噪点再闭运算连断线最后做小面积剔除。开运算imerode后imdilate或用bwareaopen等效实现用半径 1 的圆盘结构元专门清掉孤立的一两个像素噪点。闭运算用稍大一点的结构元半径 2 到 3把裂纹断口之间的缝隙填上让一整个缺陷变成同一个连通域避免后面统计时把一条裂纹拆成三四个小目标每个都不够阈值而被误判为噪声。这个顺序如果反了先闭运算会把噪点也连成块后面反而更难处理。面积剔除用bwareaopen但这个阈值得小心设。一条 1 mm 长、0.08 mm 宽的裂纹在 0.033 mm/pixel 的当量下面积大约是 (1/0.033)×(0.08/0.033) ≈ 30×2.4 ≈ 72 个像素。所以最小面积阈值设在 20 到 30 比较合适设到 100 就会开始漏检短裂纹。我在实际项目里会把这个值设得偏低一点比如 15把“是不是真裂纹”的判断完全交给后面的特征筛选而不是在这一步提前砍掉。还有一个容易被忽视的细节瓶子边缘的轮廓线也会被顶帽提取出来。瓶壁与背景的交界处是强梯度形态学处理后会形成一条沿轮廓的长条亮区面积大、形状细长非常容易被当成裂纹。解决办法是在处理前用一张“有效检测区域掩膜”把瓶体轮廓附近若干像素排除掉只保留瓶壁中段的平滑区域。掩膜可以按瓶型固定生成也可以在每帧里用大津法分割瓶体轮廓后往里收缩 5 到 10 个像素得到。源码里用的是固定掩膜加一次形态学收缩简单够用。4. 特征筛选把合缝线、水痕、划痕从候选里摘出去4.1 连通域几何特征表走到这一步二值图里剩下的连通域就是“候选缺陷”。接下来要做的就是用regionprops把这堆候选的几何属性全算出来再用一组规则打分。单靠一个特征永远不够必须组合。下面这张表是我在实际项目里反复调整后沉淀下来的判定逻辑参数值仅供参考不同瓶型肯定要重调。特征计算方式真裂纹的典型范围主要拦截对象面积 Arearegionprops直接给30 ~ 2000 像素噪点过小、大片污渍过大长宽比长轴/短轴大于 4气泡、结石接近 1占空比 Extent面积/外接框面积0.15 ~ 0.6块状水痕接近 1偏心率 Eccentricityregionprops直接给大于 0.95圆形/椭圆形干扰平均顶帽响应候选区域内顶帽图均值高于全局均值 1.8 倍弱对比度的料纹、浅划痕位置质心坐标不在合缝线掩膜内合缝线打分方式我一般用“全部满足才判为裂纹”的硬规则而不是加权求和。原因是工业检测里漏检的代价远高于过杀硬规则更容易追溯——出现误判时能明确说是哪一条没过、哪一条过了调起来有方向。加权打分虽然灵活但一旦出问题就是黑盒排查成本很高。4.2 合缝线的特殊处理前面提到合缝线靠几何特征打不掉这里详细说下我用的方法。思路是在标定阶段采集若干张良品图统计出每一列或者某一个角度扇区内的顶帽响应均值找出响应持续偏高的那一段列范围把它标记为“永久屏蔽区”。检测时如果某个候选的质心落在这个范围内直接丢弃。这个方法的有效性完全依赖于瓶子在夹具中的角向定位精度。如果产线用的是星轮加定位块的机械结构重复精度通常没问题如果瓶子是自由落在输送带上的角位置会漂那就得换方案——比如在检测工位前加一个旋转机构让瓶子转到一个固定角度再触发拍照或者干脆用两个相机从不同角度拍取“两个视角都认为是裂纹”的结果作为最终判定合缝线因为只在固定角度出现自然就被排除了。注意屏蔽区的宽度不要设得太宽。有些裂纹恰好出现在合缝线附近屏蔽区一旦覆盖过大就会造成系统性漏检。我的经验是把屏蔽区宽度控制在合缝线实际像素宽度的 1.5 倍以内宁可留一点过杀。除了位置屏蔽还有一个辅助判据值得加合缝线的亮度沿纵向非常均匀而裂纹的亮度分布是起伏的中间亮两头暗。计算候选区域内顶帽响应的标准差裂纹通常明显高于合缝线。这个特征加进去之后合缝线的误判率能再降一个档次。5. 从跑通 Demo 到上产线耗时、标定、打包5.1 耗时优化先测量再动手Matlab 写图像处理最大的坑不是写不出来而是写出来太慢。我的习惯是先加计时打点把整个流程拆成读图、预处理、二值化、形态学、连通域分析、判定六段看看到底哪一段吃时间。实测下来500 万像素的图耗时排名通常是读图和格式转换 连通域分析 形态学 顶帽 阈值。这个排序和很多人的直觉不一样但确实如此。针对性优化手段有这几个。第一ROI 裁剪要趁早在读图之后立刻裁不要等做完预处理再裁imread加imcrop的顺序能省下大量内存拷贝。第二避免在循环里调用imshow产线模式必须关掉所有可视化只在调试模式下开。第三regionprops尽量少要属性特别是不需要PixelIdxList的时候就别要它返回的是元胞数组每个连通域一份像素索引数量一多就是灾难。第四能用向量化就别写 for 循环Matlab 的循环虽然在改进但图像处理里逐像素循环仍然是性能杀手。如果做完这些还是不够快可以考虑把核心算法用 Matlab Coder 生成 C 代码或者把整个流程用 MATLAB Compiler 打包成独立可执行程序在目标机上用运行时跑。打包之后的启动开销比在 IDE 里跑小很多这一点在产线环境里很关键——工控机开机就要能用不能依赖打开一个庞大的开发环境。5.2 标定与验证漏检率和过杀率要分开看算法调完之后必须做量化评估不能靠“我看着还行”。评估至少要准备两类样本正样本含真实裂纹最好覆盖不同位置、不同长度、不同走向和负样本良品但包含各种干扰。样本量太小没有意义我一般要求正样本不少于 100 个缺陷实例负样本不少于 300 个。评估指标上漏检率和过杀率一定要分开统计。漏检率是缺陷被放过的比例这个在工业场景里通常是硬指标要求接近零过杀率是良品被误判的比例一般允许 3% 到 8%但太高会影响产线效率。两者是一对矛盾把阈值调低能降漏检但升过杀调高则相反。源码里附了一个批量评估脚本跑完会输出混淆矩阵和每个样本的判定结果方便你定位到底是哪一类样本被误判了。还有一个很少被提及但很重要的点验证集不能和调参集重合。我见过有人拿同一批图反复调阈值调到完美换一批新样本立刻崩盘。正确做法是把样本分成调参集和验证集调参只在调参集上做最终指标只在验证集上跑。这个习惯能帮你避免大量的自我欺骗。6. 一套可以直接复现的 Matlab 代码骨架与 GUI 入口6.1 主检测函数下面这段是核心检测函数参数全部集中在一个结构体里方便批量调参。它把前面讲的“顶帽增强→滤波→二值化→形态学→连通域→特征打分”整条链路串了起来返回判定结果和标注图。function out crackDetect(imgPath, cfg) % crackDetect 玻璃瓶裂纹检测主函数 % 输入: imgPath 图像路径; cfg 参数结构体 % 输出: out.isCrack 是否含裂纹; out.marked 标注图; out.stats 特征表 % ---------- 1. 读图与 ROI ---------- I imread(imgPath); if size(I, 3) 3 I rgb2gray(I); end I im2double(I); if ~isempty(cfg.roi) I imcrop(I, cfg.roi); end % ---------- 2. 照度校正(可选) ---------- if ~isempty(cfg.bgTemplate) bg cfg.bgTemplate; I imdivide(I, bg 1e-6); I mat2gray(I); end % ---------- 3. 顶帽增强 ---------- se strel(disk, cfg.topHatRadius); I_top imtophat(I, se); % 亮细节(暗场下的裂纹) I_top imgaussfilt(I_top, cfg.sigma); % ---------- 4. 二值化 ---------- if cfg.useOtsu lv graythresh(I_top); else lv cfg.fixedThresh; end BW imbinarize(I_top, lv * cfg.threshScale); % ---------- 5. 形态学 ---------- BW bwareaopen(BW, cfg.minArea); % 去小噪点 BW imclose(BW, strel(disk, cfg.closeRadius)); % 连接断口 BW imopen(BW, strel(disk, 1)); % 屏蔽区(合缝线、瓶体轮廓)置零 if ~isempty(cfg.mask) BW BW cfg.mask; end % ---------- 6. 连通域特征 ---------- st regionprops(BW, I_top, ... Area, BoundingBox, MajorAxisLength, MinorAxisLength, ... Eccentricity, Extent, Centroid, MeanIntensity); n numel(st); keep false(n, 1); rec zeros(n, 6); for k 1:n elong st(k).MajorAxisLength / max(st(k).MinorAxisLength, eps); rec(k, :) [st(k).Area, elong, st(k).Extent, ... st(k).Eccentricity, st(k).MeanIntensity, st(k).Centroid(1)]; keep(k) st(k).Area cfg.minArea ... st(k).Area cfg.maxArea ... elong cfg.minElong ... st(k).Extent cfg.maxExtent ... st(k).Eccentricity cfg.minEcc ... st(k).MeanIntensity cfg.minResponse; end % ---------- 7. 输出 ---------- out.isCrack any(keep); out.marked insertShape(im2uint8(I), Rectangle, ... vertcat(st(keep).BoundingBox), ... Color, red, LineWidth, 2); out.stats rec; out.keepIdx find(keep); end这段代码里有几个地方是刻意这么写的。imtophat放在滤波之前是为了先提细节再平滑噪声顺序反了细节会被平滑掉。regionprops一次性把MeanIntensity也拿了这个属性需要传入顶帽图I_top作为灰度参数它给的是候选区域的原始顶帽响应均值直接对应“这个候选够不够亮”是最有效的强弱判据。keep用的是逐条的硬规则出问题时打印rec矩阵一眼就能看出是哪个候选卡在哪一条上。6.2 批量评估与 GUI 入口单张跑通之后第一件事是写批量评估脚本不然你永远不知道真实的漏检率和过杀率。下面这个脚本扫两个文件夹正样本目录装含缺陷图负样本目录装良品图跑完输出混淆矩阵。function report batchEval(posDir, negDir, cfg) posList dir(fullfile(posDir, *.png)); negList dir(fullfile(negDir, *.png)); TP 0; FN 0; FP 0; TN 0; tic; for i 1:numel(posList) r crackDetect(fullfile(posDir, posList(i).name), cfg); if r.isCrack, TP TP 1; else, FN FN 1; end end for i 1:numel(negList) r crackDetect(fullfile(negDir, negList(i).name), cfg); if r.isCrack, FP FP 1; else, TN TN 1; end end elapsed toc; report.missRate FN / max(TP FN, 1); % 漏检率 report.overkill FP / max(FP TN, 1); % 过杀率 report.avgTime elapsed / (numel(posList) numel(negList)) * 1000; report.confMat [TP FN; FP TN]; fprintf(漏检率 %.2f%% 过杀率 %.2f%% 平均耗时 %.1f ms\n, ... report.missRate*100, report.overkill*100, report.avgTime); endavgTime这个字段别嫌它多余它决定了你的算法能不能塞进产线节拍。如果平均耗时超过预算就得回头做 5.1 里的那些优化。要给人看效果的话用 App Designer 搭个最小界面就够了一个uiaxes显示原图一个uiaxes显示标注图一个滑块调阈值一个按钮切图。回调里直接调crackDetect把out.marked塞给坐标区。滑块的作用很大——让工艺人员能自己感受阈值变化对漏检和过杀的影响比你在报告里写十页说明都管用。源码包里带了一个精简版的界面脚本核心就三十来行可以直接改。7. 调试过程中最容易翻车的几个地方第一个坑是拿实验室图调完参数就直接上产线。实验室里的瓶子和产线上的瓶子状态差很远——产线上的瓶子可能有冷凝水、有细微粉尘、有刚从退火炉出来带着余温导致的轻微形变这些都会改变图像的灰度分布。我一般的做法是至少在产线连续采三种不同状态的样本刚下线、冷却后、清洗后各几十张全部纳入调参集。第二个坑是把结构元尺寸当成万能钥匙。顶帽的半径、闭运算的半径、面积阈值这三者之间有耦合关系改一个就得重看另外两个。比较稳妥的调法是把它们固定成一组基线参数先只动阈值缩放到能分割出大部分缺陷再微调形态学参数让断线连上最后才动面积和长宽比这些判定条件。一次只动一个动完立刻在固定验证集上跑一遍看指标别凭感觉。第三个坑是忽略了瓶型的差异。同一个算法在浅色瓶和深色瓶上的表现可能天差地别深色瓶的透光率低暗场效果反而更好浅色瓶则容易出现大面积高光。如果你的产线会换瓶型参数必须做成配方化的按瓶型编号切换一整套cfg不要指望一套参数打天下。第四个坑是只做单帧判定。真实的漂移是缓慢发生的比如光源老化、镜头积灰、瓶体批次变化。代码再稳也建议加一层趋势监控统计最近 1000 个瓶子的候选缺陷数量均值一旦偏离基线超过阈值就报警提示检查光源和镜头。这个功能实现起来不到二十行但能帮你提前几天发现问题而不是等客户投诉才知道。最后分享一个小技巧把每次判定的候选区域和特征值都写进日志哪怕只记前十个候选的面积、长宽比、响应均值出了问题拿日志回溯比重新复现现场容易得多。我吃过没有日志的亏一个偶发的过杀查了整整两天后来加上日志第三次出现时五分钟就定位到是某个角度的反光被误判了。