MATLAB形态学与边缘算法:药片有无及缺陷检测实践 简介面向MATLAB图像处理学习者与药品包装质检方向的入门实践者这份资料围绕药片缺陷检测与有无判断整合了小波去噪、自适应中值滤波、形态学重构、边缘算子提取及扫描匹配等环节可用于课程设计、实验复现或视觉检测思路参考。压缩包共15个文件、约1.78MB包含5个m脚本、7张jpg示例图以及2份doc和1份docx说明文档脚本承载算法实现图片便于观察处理效果文档补充背景与结果说明。已有1578人学习下载具备一定参考热度。其价值在于把从噪声抑制、背景去除、边缘二值化到目标标定与好坏有无判定的完整流程集中呈现读者可据此理解形态学与边缘算法在药片检测中的配合方式并借助样例图与说明文档对照分析各处理阶段的效果与参数影响。1. 药片缺陷和有无检测真正耗时的是先想清楚「判什么」灯检工位上最容易跳过的一步是先分清「有无」和「缺陷」两件事。有无检测只要数连通域个数一片一个团块少一个就报警缺陷检测要在直径两百多像素的圆盘里找崩边、裂纹和黑点判据完全不是一回事。我见过不少版本把两者塞进同一条 Canny 流程结果计数很稳缺陷判定天天翻车。这套基于 MATLAB 形态学与边缘算法的脚本把两件事拆成两条链路形态学负责「切出药片、数清楚、量外形」边缘算法只做内部的报警器。适合已经会用 imread、regionprops但卡在阈值和结构元怎么定的人。2. 灰度归一化顶帽变换把光照不匀压到 ±5 灰阶同一盒药片早上开机和下午连跑两小时阈值能差出 0.1 以上这不是算子的问题是光源和镜头渐晕的问题。工业相机配环形光画面中心比四角亮 15% 到 25% 很常见全局 Otsu 在这种梯度上会切出一条斜的边界左边药片被啃掉一角右边背景被算成一片。所以整条链路的第一段不是分割而是把「背景亮度」从图里减掉。这一段做不好后面所有形态学参数都是白调。2.1 灰度化不是一行 rgb2gray 的事多数缺陷在灰度上是有响应的崩边是几何缺口裂纹是细暗线这两类用灰度足够。但黑点、黄斑、油渍这类色差缺陷rgb2gray 的加权公式会把红色和绿色通道的差异抹平一个饱和度很高的黄斑转成灰度后只比周围暗 3 到 5 个灰阶直接掉进噪声里。常见做法是双通道并行灰度通道走形态学和边缘饱和度通道单独做一次低阈值检测两路结果做逻辑或。function [grayFlat, satMap] normalizeWafer(imgPath, seR) % 输入单张工位图路径seR背景估计结构元半径像素 img imread(imgPath); if size(img,3) 3 gray im2double(rgb2gray(img)); hsv rgb2hsv(img); satMap hsv(:,:,2); % 饱和度通道黄斑、油渍在这里更明显 else gray im2double(img); satMap zeros(size(gray)); end se strel(disk, seR, 0); % 圆盘结构元半径必须大于单片成像半径 bg imopen(gray, se); % 开运算把药片抹掉只剩背景亮度分布 top imsubtract(gray, bg); % 顶帽保留比局部背景亮的细小结构 grayFlat mat2gray(top); % 按全图极值拉回 [0,1] end逻辑上就三步开运算用一个大结构元腐蚀再膨胀凡是尺寸小于结构元的亮块都会被消掉剩下的就是缓变的背景原图减背景得到顶帽图光照梯度被减掉药片本体保留mat2gray 把动态范围拉回 0 到 1方便后面写死阈值。seR 怎么定是唯一需要动脑的参数——它必须大于单片药片的成像半径同时小于画面里最大的亮度起伏尺度。单片直径 230 像素时半径 115seR 取 130 到 150 之间比较稳取 60 会把药片本身当成背景减掉得到一张几乎全黑的图。提示如果药片比背景暗深色糖衣片压在浅色托盘上顶帽会失效改用 imbothat 做底帽或者先 imcomplement 再走同一套流程。2.2 拉伸与均衡的边界adapthisteq 会放大什么顶帽之后很多人顺手接一个 adapthisteq画面立刻「好看」了裂纹也清楚了但噪声同样被拉起来。自适应直方图均衡是按小块统计的块内对比度低时会给出很大的增益托盘上的细微划痕、镜头灰尘会被放大成和裂纹同量级的亮线后面 Canny 一跑全是假边缘。我的习惯是默认不用只在裂纹对比度确实不够时开并且把 ClipLimit 压到 0.01 以内、NumTiles 控制在 8×8让增益有上限。% 可选增强只在裂纹信噪比不足时打开 if cfg.useClahe grayFlat adapthisteq(grayFlat, ClipLimit, 0.01, ... NumTiles, [8 8], Range, full); endClipLimit 是裁剪限幅越小增益越保守NumTiles 是分块数分得越细局部对比度越强、块边界伪影也越明显。这两个参数没有普适值但有一个判断标准把一张良品图跑一遍如果托盘区域出现成片的亮斑说明增益过头了往回压。2.3 归一化阶段的参数表参数作用标定方法典型取值seR背景估计结构元半径取单片成像半径的 1.1 到 1.3 倍130 到 150 像素useClahe是否做局部均衡良品图跑一遍看托盘有无亮斑falseClipLimit均衡增益上限从 0.005 起调出现块边界伪影就停0.01NumTiles分块粒度与缺陷最小尺寸对齐不小于 8[8 8]这一段跑完输出应该是一张背景接近纯黑、药片灰阶集中在 0.4 到 0.9 之间的图。如果输出里还能看出明显的左上暗右下亮别往下走回去把 seR 调大或者检查光源有没有装匀。此处偷懒后面每个参数都得加倍偿还。3. 形态学抠药片面积窗口、结构元与缺片计数的三个硬参数有无检测的核心就是一句话把每片药变成一个连通域然后数个数、量面积。听起来简单实际卡人的是边界——反光造成的空洞、粉尘造成的碎点、两片粘连成的哑铃形都会让计数偏。形态学在这一段的作用不是「美化」而是把不符合「一片药应该长什么样」的东西逐个剔掉让连通域的数量等于药片的数量。3.1 Otsu 和自适应阈值的分工imbinarize 有两个常用入口全局 Otsu 和自适应。选错的代价很直接。场景推荐方法理由光源稳定、背景单色、药片与托盘对比度 0.3全局 Otsu默认 global阈值单一批间一致性好不会因局部噪声抖动有大面积反光、托盘有纹理、视野内亮度不均自适应adaptive按邻域算阈值能压住缓变背景深色药片压在浅托盘上Otsu imcomplement极性反了先取反再二值化药片上有刻字、凹槽自适应 后处理填洞刻字会在片内造成暗区全局阈值容易挖出孔我一般先用全局 Otsu 跑一批看连通域数量稳不稳只有当同一批里出现整幅漏检或整幅过检时才换自适应。自适应阈值有个隐蔽问题Sensitivity 参数会把阈值整体偏移0.45 和 0.50 在小目标上可能差出一个像素的边界而面积判据正是靠边界吃饭的。function [bwPills, stats] segmentPills(grayFlat, refArea) % refArea标定得到的单片面积像素用于面积窗口 bw imbinarize(grayFlat, adaptive, ... ForegroundPolarity, bright, ... Sensitivity, 0.48); bw bwareaopen(bw, round(0.35 * refArea)); % 去掉粉尘、反光碎点 bw imfill(bw, holes); % 填掉刻字凹槽造成的孔 bw imopen(bw, strel(disk, 2)); % 去边缘毛刺半径别超过 3 bw imclearborder(bw); % 去掉压在画面边缘的半片 cc bwconncomp(bw, 8); stats regionprops(cc, Area, Centroid, BoundingBox, Solidity, ... MajorAxisLength, MinorAxisLength, ConvexArea); area [stats.Area]; keep area 0.55*refArea area 1.45*refArea; % 正常片面积窗口 bwPills ismember(labelmatrix(cc), find(keep)); stats stats(keep); end四个后处理各有分工。bwareaopen 按面积砍阈值取 0.35 倍单片面积能把粉尘和反光点清掉又不会误伤边缘破损的合格片imfill 填内部孔洞这一步很关键不填的话后面算面积、算凸包全是错的imopen 用半径 2 的圆盘去毛刺半径再大就会把崩边的缺口也磨平等于把缺陷藏起来imclearborder 清掉贴边的半片代价是画面边缘的药片不参与计数所以工装设计时要在视野四周留出至少 20 像素余量。注意imclearborder 会让「缺片」和「药片压在边缘」两种情况产生同样的结果——数量少一个。如果工装做不到留边就必须把贴边的连通域单独标记出来人工复核不能直接当缺片报警。3.2 面积窗口是「缺片」和「粘连」的分界线面积窗口的下限 0.55 倍和上限 1.45 倍不是随手写的。正常片的面积波动主要来自两点二值化边界的一两个像素抖动以及药片本身的尺寸公差实测下来同一批的变异系数在 3% 以内所以 0.55 到 1.45 是很宽松的窗口留足了余量。上限真正的作用不是滤掉大药片而是把粘连挑出来。两片粘在一起面积接近 2 倍落在窗口外会被 keep 排除——如果直接丢掉计数就少了一个误判成缺片正确做法是把超出上限的连通域单独收集用水分岭或距离变换切开再计数。% 粘连候选面积超上限的连通域用距离变换分水岭拆开 bigIdx find(area 1.45 * refArea); for k bigIdx(:) m false(size(bw)); m(cc.PixelIdxList{k}) true; D bwdist(~m); % 距离变换粘连处形成鞍部 D -D; D(~m) -Inf; L watershed(D); n numel(unique(L(L 0))) ; % 拆出来的片数 fprintf(连通域 %d 拆分为 %d 片\n, k, n); end距离变换把每个前景像素到最近背景的距离算出来两片粘连的地方距离值最小形成鞍部取负之后做分水岭水线正好落在鞍部上就把哑铃劈成两半。这个处理只在面积超上限时触发正常片不进这条分支所以不影响节拍。3.3 缺片判定的写法数量对了不代表没缺片——如果多出一片粉尘被误判成药片数量可能刚好凑齐。所以判定要双重数量比对加面积分布比对。nPill numel(stats); if nPill ~ cfg.expectedCount verdict MISSING; % 数量不符直接判缺片 elseif std([stats.Area]) / mean([stats.Area]) 0.08 verdict SUSPECT; % 数量对但面积离散度大疑似混入异物 else verdict OK; endcfg.expectedCount 来自工装比如 5×6 的泡罩板就是 30。面积离散度阈值 0.08 是经验值正常批在 0.02 到 0.04 之间超过 0.08 基本能确定混进了碎片或者有片被切掉一半。这一段的输出不只是 OK/NG还要把 Centroid 存下来和工装的理论坐标做比对——哪一格空了一眼就能看出来比只看总数有用得多。4. 边缘算法当报警器Canny 双阈值加环形带屏蔽把边缘算法直接拿来做缺陷判定是新手最常见的翻车点。原因很朴素Canny 会把所有灰度突变都标出来包括药片自己的外轮廓。一张 30 片的图跑完边缘像素里九成以上来自外轮廓剩下一点噪声真正的裂纹只占几十个像素任何基于边缘总量的判据都会被外轮廓淹没。所以这一段的关键不是选 Canny 还是 Sobel而是先做一个环形带屏蔽把药片自己的边界从边缘图里删掉。4.1 Canny 内部四步两个阈值在控制什么Canny 的执行顺序是高斯平滑 → 计算梯度幅值和方向 → 非极大值抑制 → 双阈值加滞后连接。edge(I,Canny,[low high],sigma) 里的三个参数分别对应后三步sigma 控制高斯核标准差越大越平滑弱边缘丢得越多噪声也越少。纹理粗糙的托盘用 1.2 到 1.6表面光洁的药片用 1.0 就够。low 和 high 是梯度幅值的归一化阈值0 到 1high 决定「强边缘种子」low 决定「从种子出发能延伸多远」。high 定得太低噪声全成种子low 定得太高裂纹断成几截连不起来。两者比例一般取 2 到 3 倍比例越大边缘越保守。我通常先把 high 调到一个「良品片上只剩外轮廓和刻字」的水平再把 low 压到 high 的三分之一让裂纹这种弱边缘能被强边缘带着长出来。这个过程要在良品图和 NG 图上对着调只调一边一定会偏。% 参数扫描同一张 NG 图看不同阈值下的边缘像素数 eMap edge(flat, Canny, [0.05 0.15], 1.2); fprintf(阈值 [0.05 0.15] sigma 1.2 → %d 像素\n, nnz(eMap));用 nnz 输出像素数是最快的粗调手段良品图的边缘总量应该稳定在一个小范围里波动超过 30% 说明阈值踩在噪声敏感区往下走一定会出问题。4.2 环形带屏蔽先把药片自己的轮廓删掉思路是把每片药的边界往外扩几个像素形成一圈缓冲带边缘图里落在这圈带子上的像素全部作废只保留片内深处的边缘。function defects inspectPill(flat, pillMask, cfg) % flat归一化灰度图pillMask单片逻辑掩膜cfg阈值结构体 pillMask imfill(pillMask, holes); ring imdilate(bwperim(pillMask), strel(disk, cfg.ringW)); % 外轮廓缓冲带 inner pillMask ~ring; % 只留片内区域 eMap edge(flat, Canny, [cfg.cannyLow cfg.cannyHigh], cfg.cannySigma); eIn eMap inner; % 屏蔽掉药片自身轮廓 eIn bwareaopen(eIn, cfg.minEdgePix); % 干掉孤立单像素噪点 s regionprops(pillMask, Area, ConvexArea); defects.edgeDensity nnz(eIn) / max(nnz(inner), 1); defects.solidity s.Area / s.ConvexArea; defects.isNG defects.edgeDensity cfg.edgeDensityMax || ... defects.solidity cfg.solidityMin; endringW 是关键参数。太小缓冲带压不住外轮廓的抖动良品片也会被算进边缘密度太大靠近边沿的崩边会被一起屏蔽掉。实测取 5 到 8 像素比较合适对应二值化边界的抖动幅度通常 2 到 3 像素的两倍多。bwareaopen 的 minEdgePix 取 8 到 15意思是小于这个长度的边缘段直接丢掉——裂纹是连续的噪声是零散的按长度区分比按灰度区分可靠。edgeDensity 是片内边缘像素占片内总面积的比例。良品片上还有刻字所以不是 0标定时把 50 片良品的 edgeDensity 打出来取均值的 3 倍标准差作为上限一般落在 0.02 附近。4.3 崩边用闭运算差分裂纹和黑点用底帽变换边缘密度是个笼统指标只能报「这片有问题」说不出是什么问题。要分类就得补两条专用通道。崩边是几何缺陷用形态学的闭运算最直接闭运算能填平凹口和原掩膜做差差出来的面积就是缺口。r round(0.06 * sqrt(nnz(pillMask)/pi)); % 与片径成比例约 6% 半径 closedM imclose(pillMask, strel(disk, max(r, 3))); chipArea nnz(closedM) - nnz(pillMask); % 闭运算包含原集合差值非负 defects.chipRatio chipArea / nnz(pillMask);r 取值必须和药片尺度绑定写成固定像素在不同分辨率下会全错。6% 半径的含义是小于 6% 半径的凹口会被填平更大的凹陷比如被切掉半个留到 solidity 去抓。chipRatio 超过 0.01 就报崩边这个阈值对应半径方向上约 1% 的面积损失肉眼已经能看出来。裂纹和黑点是「比周围暗的细结构」用底帽变换。imbothat 等于闭运算减原图闭运算会把细暗线填掉减完就只剩这条线。bth imbothat(flat, strel(disk, 3)); % 半径 3 大于裂纹宽度小于药片尺度 darkMask bth cfg.darkThresh inner; darkMask bwareaopen(darkMask, cfg.minDarkPix); defects.darkRatio nnz(darkMask) / nnz(inner);结构元半径 3 是配合裂纹宽度定的二值化后裂纹一般 1 到 2 像素宽闭运算用半径 3 的圆盘能完全填掉而刻字的笔画宽 4 到 6 像素填不掉所以刻字不会误报。darkThresh 用良品图的底帽结果标定取 99 分位数再上浮 20%。4.4 四条判据的组合表判据计算方式典型阈值主要抓什么edgeDensity屏蔽后边缘像素 / 片内面积 0.02综合异常、细碎裂纹chipRatio闭运算差分面积 / 片面积 0.01崩边、缺角darkRatio底帽超阈值面积 / 片内面积 0.004裂纹、黑点、油污solidity面积 / 凸包面积 0.97大缺口、被切掉一块四条判据是逻辑或的关系任何一条越界就判 NG。但输出要带上越界的是哪一条这样现场排查时能立刻区分是光源脏了darkRatio 集体升高还是工装偏移导致边缘被切solidity 集体下降。全部 NG 集中在同一判据上八成不是药片的问题。5. 避坑与排查五条踩过的坑从全白画面到粘连漏检这一段是整篇最值钱的部分。参数可以慢慢试但这几个坑不知道会以为是算法不行其实是流程里少了一步。5.1 换批次后整幅全白或全黑现象同一套参数上一批跑得好好的换一批药片后二值化结果整幅是前景或者整幅是背景regionprops 返回 0 个或 1 个连通域。原因顶帽之后用 mat2gray 做全局拉伸它的映射范围是本图的最小值和最大值。如果这一批的药片颜色偏浅顶帽残差整体偏小拉伸会把噪声也放大到 0 到 1 的中段自适应阈值配合 Sensitivity 0.48 就容易全判前景反过来如果这一批对比度高拉伸后大片区域接近 0.9阈值切不住。解决把 mat2gray 换成固定范围的线性映射范围由标定批次的 1% 和 99% 分位数确定写进配置。这样不同批次落在同一个尺度上阈值才有可比性。lo prctile(top(:), 1); hi prctile(top(:), 99); grayFlat min(max((top - lo) / max(hi - lo, eps), 0), 1);另外空盘图像一片都没放时 top 的最小值和最大值几乎相等mat2gray 会返回全零分割出来 0 个连通域正好判成缺片逻辑上没错但要确认这不是「相机没触发、读到空图」。5.2 两片粘连算成一片缺片误报率飙升现象明明满板 30 片计数结果是 29标注出来那一片的位置明显偏大长宽比接近 2。原因面积上限 1.45 倍把粘连的连通域排除了代码当它是「异常大目标」丢掉数量自然少一个。解决不要直接丢走 3.2 节的面积超限分支用距离变换加分水岭拆开。同时把 imopen 的结构元半径压到 2 以内——半径大了会把两片之间的细颈先腐蚀断看似分开了但面积各少一块后面崩边判据会集体误报。5.3 每片都报 NG边缘密度普遍超标现象良品图跑下来 edgeDensity 全部在 0.05 以上判据全线越界但肉眼看不出缺陷。原因环形带没做或者 ringW 太小。Canny 把药片外轮廓完整地标了出来一个直径 230 像素的圆周长约 720 像素片内面积约 41500光外轮廓就贡献 1.7% 的边缘密度加上刻字直接超过 2% 的阈值。解决确认 bwperim 加 imdilate 的屏蔽链路真的生效打印一次 ring 的像素数看看数量级对不对ringW 从 5 起往上试直到良品片的 edgeDensity 稳定在 0.01 以下。如果屏蔽之后还是高检查 imfill 有没有做——没填洞的掩膜bwperim 会把内部孔洞的边界也画出来。5.4 深色药片怎么调都是零连通域现象分割结果全黑bwareaopen 之后什么都不剩。原因顶帽只保留比背景亮的结构。深色药片压在浅色托盘上药片比背景暗顶帽残差在药片位置接近 0在托盘位置反而很大等于把前景背景搞反了。解决先判断极性。用良品图算药片区域和托盘区域的均值如果药片更暗走 imcomplement 分支或者把 imopen 换成 imclose 做底帽。这里不要靠试写一行判断让代码自己选。if mean(gray(maskKnownPill)) mean(gray(maskKnownTray)) gray imcomplement(gray); % 统一成药片比背景亮再走后续流程 end5.5 结构元尺寸拍脑袋缺陷被自己抹平现象调完参数后缺陷检出率骤降肉眼能看到的崩边全被判成良品。原因imopen 去毛刺的半径用了 5imclose 算崩边的 r 又只有 2。前一步把 5 像素以内的凹口磨平了后一步再去填 2 像素以内的凹口等于缺陷在预处理阶段就被消灭了。解决结构元尺寸必须成体系地定并且写进一份参数表里任何一步的半径都不能大于要检测的最小缺陷尺度。经验规则是去噪的 imopen 半径 ≤ 2崩边闭运算半径取片半径的 6%底帽半径取裂纹宽度的 2 倍三者不相交。注意每改一次结构元半径前面的面积窗口和后面的判据阈值都要重新标定。这不是保守是因为面积窗口的基准 refArea 本身随边界抖动在变。6. 批处理与阈值标定一次跑完 200 张图的验证姿势单片分析调通之后真正的考验是批量一致性。我的习惯是攒够 200 张图再动手120 张良品、80 张 NG崩边、裂纹、黑点、缺片各 20 张全部用同一套光源和工装拍文件名里带上人工标注结论这样脚本能直接算混淆矩阵。批量循环本身没什么难的要注意两件事一是每片单独取掩膜时不要重复做 bwconncomp一次算完在循环里取 PixelIdxList二是把耗时打出来30 片的节拍要能压进 0.5 秒以内超了就去看是不是 adapthisteq 或者反复 imread 拖慢了。files dir(fullfile(root, *.png)); rows {}; for k 1:numel(files) t0 tic; [flat, ~] normalizeWafer(fullfile(root, files(k).name), cfg.seR); [bw, st] segmentPills(flat, cfg.refArea); if numel(st) ~ cfg.expectedCount verdict MISSING; else cc bwconncomp(bw, 8); verdict OK; for i 1:cc.NumObjects m false(size(bw)); m(cc.PixelIdxList{i}) true; d inspectPill(flat, m, cfg); if d.isNG, verdict NG; break; end % 一片 NG 整板 NG end end rows{end1} {files(k).name, numel(st), char(verdict), toc(t0)}; end T cell2table(vertcat(rows{:}), VariableNames, ... {file,count,verdict,seconds});标定的核心不是找「最优阈值」而是找一组让漏检为零、误检可接受的阈值。做法是把 200 张图的四条判据值全部导出来按人工标注分组画分布取良品组的上限99 分位和 NG 组的下限之间的空隙中点。如果两组分布重叠说明这条判据区分度不够换判据比调阈值有用。判据良品 99 分位NG 组 1 分位取值重叠情况edgeDensity0.0140.0310.020无重叠可分chipRatio0.0060.0180.010无重叠可分darkRatio0.0030.0090.004轻微重叠需配合长度过滤solidity0.9820.9510.970无重叠可分darkRatio 有重叠是正常的因为药片上的刻字深浅不一某些批次的刻字会被底帽抓到一点。这时候不要硬调阈值改成按连通域的伸长度过滤裂纹的长宽比普遍大于 4刻字的笔画长宽比在 2 附近加一道 regionprops 的 MajorAxisLength/MinorAxisLength 过滤就能把重叠区切开。验证阶段的另一个技巧是留一批「边界样本」不进标定、只做测试边缘破损不到 2% 的合格片、颜色异常但无缺陷的片、正反面对比度差异大的片。这批样本上的误检率才是这套参数上线后真实的误报水平。最后说个血泪教训。我早期图省事把整套参数写死在函数里的常量结果换了个工位重装光源焦距差了 4 毫米单片成像半径从 115 变成 98顶帽的结构元、面积窗口、崩边半径全都不对了30 片全判 NG现场排查了两个小时才发现是焦距。从那以后我每次部署新工位第一件事就是拿一张良品图跑一遍参数自检把测出来的片径和面积打到日志里和配置值差 10% 以上直接报警停机不给自己留后悔药。希望帮到你。本文还有配套的精品资源点击获取