ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

药片缺陷检测:Otsu阈值分割、形态学与Canny边缘实践

药片缺陷检测:Otsu阈值分割、形态学与Canny边缘实践

简介:面向图像处理课程设计、机器视觉入门和药品缺陷检测实验的MATLAB实践资料,围绕药片缺陷识别与有无判断,串联小波去噪、自适应中值滤波、形态学重构、边缘提取及二值图像目标扫描匹配等环节。对高斯噪声采用小波去噪,对椒盐噪声引入自适应中值滤波,并尽量保持无噪声图像处理后不失真;随后以形态学重构抑制复杂背景,再由边缘算子获取边缘信息并生成二值图像。压缩包共15个文件、约1.78MB,含5个m脚本、7张jpg测试图以及2份doc和1份docx说明文档;脚本覆盖主流程、检测、边缘函数与自适应中值滤波等模块,图片可用于复现实验和调参,文档则帮助梳理算法组织与实现思路。已有1578人学习下载,适合需要从噪声抑制、背景去除到目标定位完整跑通一套检测流程的读者,也可作为课程设计、算法对比或缺陷判定实验的参考底稿,便于替换样本、调整参数并观察药片好坏与有无的判断效果。

1. 药片缺陷检测的真相:为什么形态学比边缘算法更早介入

在 MATLAB 里做药片的缺陷和有无检测,多数人的第一反应是调用edge(),把边缘算法当成主角。实际跑过产线图就会发现,药片表面的浅划痕在梯度图上几乎看不见,包装铝箔的反光边缘却跳得比谁都欢,边缘图直接把整张图糊成一团。真正把结果稳住的通常是形态学那几步:先腐蚀掉噪点,再开运算断开粘连,最后用顶帽把缺陷从大背景里抠出来。边缘算法负责把一个像素归到哪边、把边界描出来,形态学负责把目标定形、把结构留住。顺序一旦搞反,后面全是血泪经验。

2. 灰度阈值分割:Otsu 为什么能在药片图里“自动”找到分界线

2.1 双峰直方图的前提:药片与背景的灰度分离度

药片检测的第一步不是找缺陷,而是把药片从背景里分出来。工业相机配背光或环形光拍出来的图,理想情况下直方图会呈现两个峰:一个是背景,一个是药片本体。Otsu 法之所以能自动给出一个阈值,靠的就是这个双峰假设色。它不需要人工设定灰度门限,而是遍历所有可能的阈值,挑出“把像素分成两类之后、两类之间差异最大”的那一个。

双峰假设在药片图上并不是天然成立的。糖衣片表面有高光,刻字凹槽是暗的,泡罩包装的铝箔会有镜面反射,这些都会在直方图中间区域堆出一片“灰地带”。当中间灰度像素占比超过两成,Otsu 给出的阈值就会往背景方向偏,结果是把药片边缘的一圈半透明区域判成背景,面积直接缩水。常见的做法是先做一次高斯平滑压掉高光尖峰,再统计直方图,确认两个峰之间的谷底是否清晰;如果谷底平坦,说明光照方案本身有问题,换打光比调算法更有效。

判断双峰是否可用,有个粗糙但管用的办法:把直方图做 16 级量化后看峰谷比。峰谷比大于 3 时 Otsu 基本可靠,小于 2 时就得考虑背景校正。这个经验值不是论文里的结论,而是踩过几次坑之后形成的直觉。

2.2 类间方差最大化的推导与 MATLAB 实现

Otsu 的核心是类间方差。设阈值 t 把像素分成背景类 C0 和前景类 C1,两类的概率分别是 ω0(t)、ω1(t),均值是 μ0(t)、μ1(t),整幅图的均值是 μT。类间方差写成:

σ²B(t) = ω0(t)·ω1(t)·(μ0(t) − μ1(t))²

这个式子等价于 (μT·ω0 − μ)² / (ω0·ω1),后者在代码里更好算,因为累计矩可以一次算完。阈值 t 从 0 扫到 255,取 σ²B 最大的那个 t 就是 Otsu 阈值。它之所以“自动”,本质是把“两类分得开”这件事量化成了方差,方差越大,说明两类在灰度轴上离得越远、重叠越少。

function T = otsu_threshold(gray) % gray: 单通道 uint8 灰度图 counts = imhist(gray, 256); % 256 级直方图统计 p = counts / numel(gray); % 归一化为概率分布 omega = cumsum(p); % ω0(t):背景累计概率 mu = cumsum(p .* (0:255)'); % 累计一阶矩 muT = mu(end); % 全图灰度均值 μT % 类间方差,分母加 eps 防止 t=0 或 t=255 时除零 sigmaB = (muT .* omega - mu).^2 ./ (omega .* (1 - omega) + eps); [~, idx] = max(sigmaB); % 取方差最大的分割点 T = idx - 1; % 索引从 1 起,灰度值从 0 起 end

omega是随阈值递增的累计概率,mu是累计一阶矩,两者相除就得到当前阈值下背景类的均值。sigmaB是一整条曲线,max拿到的位置减一才是真正的灰度阈值。这段代码有两个容易忽略的点:eps不能省,否则阈值落在两端时会出现 0/0;idx - 1也不能省,MATLAB 的数组下标从 1 开始,而灰度值从 0 开始,差一位在 256 级图上就是肉眼可见的偏移。

工程上一般不会自己手写 Otsu,graythresh已经封装好了,返回的是归一化到 [0,1] 的阈值,乘 255 才能给imbinarize用。手写版本的价值在于可以改成加权 Otsu、或者只在前景 ROI 内统计直方图,避免背景占比过大时把阈值拉偏。

2.3 光照不均时,全局阈值为什么会翻车

全局 Otsu 有一个隐含假设:整幅图的光照是均匀的。实际产线上,环形光打下来中间亮、四周暗,同一个药片在画面中心和边角的灰度能差 20 级以上。这时候一个全局阈值必然顾此失彼:按中心调,边角的药片被当成背景丢掉;按边角调,中心区域的背景噪点全被当成药片。

常见的补救思路是先把背景“拉平”再分割。用一个大结构元对原图做开运算,得到的是缓慢变化的背景估计,原图减去这个背景,剩下的就是光照无关的细节。结构元尺寸要大于药片直径,否则会把药片本身也当成背景减掉。MATLAB 里imtophat做的就是这件事,只是它输出的是亮细节,做背景校正时直接用gray - imopen(gray, se)更直观。

seBg = strel('disk', 80); % 半径需大于药片最大半径 bg = imopen(gray, seBg); % 开运算估计低频背景 flat = imsubtract(gray, bg); % 减去背景,压掉光照梯度 flat = imadjust(flat); % 重新拉伸到全动态范围 T = graythresh(flat); % 在拉平后的图上做 Otsu bw = imbinarize(flat, T);

strel('disk', 80)里的 80 不是随便写的,它取决于药片在画面里的像素半径。如果药片直径约 120 像素,结构元半径要大于 60,否则开运算会把药片“啃”掉一圈,减出来的图在药片边缘出现一圈黑环,分割结果反而更糟。imsubtract会把负值截断为 0,所以减完之后动态范围变小,需要imadjust重新拉伸,否则 Otsu 在压缩后的灰度上找分界线,精度会下降。

如果光照梯度非常强,比如画面一侧有明显阴影,单靠开运算背景估计还不够,可以换imflatfield做多项式曲面拟合,或者用adaptthresh做局部自适应阈值。局部阈值的代价是参数变多、对噪声更敏感,药片这种目标尺寸稳定的场景,全局阈值加背景校正通常已经够用。

3. 形态学运算:腐蚀膨胀到底在集合层面做了什么

3.1 结构元是“探针”:闵可夫斯基和差的几何解释

形态学不是滤波,它的数学底子是集合运算。把二值图里所有前景像素看成集合 A,结构元看成集合 B,腐蚀的定义是:A 被 B 腐蚀后,剩下的点是那些“把 B 平移过去、B 能完全落在 A 里”的平移量。写成公式是 A⊖B = {z | (B)z ⊆ A}。膨胀则相反,A⊕B = {z | (B̂)z ∩ A ≠ ∅},只要 B 的反射平移后和 A 有交集,这个位置就保留。

这两个定义决定了腐蚀和膨胀的几何效果。腐蚀让目标缩小,缩小的量等于结构元半径;膨胀让目标扩大,扩大的量也等于结构元半径。结构元在这里扮演的是“探针”的角色:探针能塞进去的地方才保留,塞不进去的地方就被削掉。所以腐蚀对孤立噪点特别狠,一个 3 像素的噪点遇到半径 5 的圆盘探针,直接消失;而一个直径 100 像素的药片,边缘只会被削掉 5 像素。

二值形态学和灰度形态学在实现上共用同一套算子,区别在于灰度图的腐蚀取的是结构元覆盖区域的最小值,膨胀取最大值。这个差别很重要:对二值图,腐蚀是“与”运算;对灰度图,腐蚀是局部最小值滤波。药片表面的暗划痕在灰度腐蚀下会被放大,亮反光在灰度膨胀下会被放大,这也是顶帽变换能提取缺陷的底层原因。

3.2 开运算去毛刺、闭运算补缺口:药片缺角的判定逻辑

单独用腐蚀或膨胀都会改变目标尺寸,工程上更常用的是两者的组合。开运算是先腐蚀后膨胀,记作 A∘B,它的几何含义是“所有能完全放入 A 的 B 的平移的并集”。翻译成人话:凡是比结构元小的凸起、毛刺、细连接,开运算都会把它们削掉,而大块目标的整体形状基本不变。闭运算是先膨胀后腐蚀,A•B,它的含义是“所有不覆盖 A 的 B 的补集的并集”,效果是填平比结构元小的孔洞和缺口。

药片缺角检测正好用得上这两个性质。完整药片的二值掩膜经过开运算后,面积几乎不变;缺了一角的药片,缺角处形成一个比结构元大的凹口,开运算填不上,面积会明显小于完整片。这里的关键是结构元尺寸要卡在“缺陷尺寸”和“药片尺寸”之间:结构元半径小于缺角尺寸,开运算才不会把缺角误填;结构元半径又要小于药片半径,否则药片整体被削。常见的取值是药片半径的 1/5 到 1/3。

seOpen = strel('disk', 8); % 半径8,对应缺角最小尺度 bwOpen = imopen(bw, seOpen); % 开运算:削毛刺、断细连 seClose = strel('disk', 12); % 闭运算结构元略大 bwClose = imclose(bwOpen, seClose); % 填平小孔、补细微缺口 stats = regionprops(bwClose, 'Area', 'Perimeter', 'Circularity'); areas = [stats.Area]; fullArea = median(areas); % 以中位面积作为完整片基准 defectIdx = find(areas < 0.85 * fullArea); % 面积低于85%判为缺角

strel('disk', 8)生成的是近似圆盘,MATLAB 内部用距离变换构造,半径参数直接对应像素。开运算放在闭运算之前是有讲究的:先削掉毛刺,再填孔,顺序反过来会把毛刺和孔洞一起封在目标内部。regionprops的Circularity是周长平方与面积之比,完整药片接近 1,缺角片会明显偏离,但它对边缘断裂很敏感,所以面积判据更稳。

面积阈值 0.85 是经验值,不是固定标准。换一批药片、换一种打光,完整片的面积基准会变,这个系数要重新标定。稳妥的做法是每批取前 20 片做基准,用中位数而不是均值,避免个别极端值把基准带偏。

3.3 顶帽变换:把细小缺陷从大背景里“抠”出来

开运算和闭运算还有一个衍生用法:顶帽和底帽。顶帽定义为原图减去开运算结果,I − (I∘B),它保留的是“比结构元小、且比周围亮”的细节。底帽是闭运算结果减去原图,(I•B) − I,保留的是“比结构元小、且比周围暗”的细节。药片表面的浅划痕、麻点、小崩边,尺度通常远小于药片本身,正好落在顶帽和底帽的捕捉范围里。

顶帽的机制可以用一句话概括:开运算把比结构元小的亮细节抹掉了,原图减掉抹掉细节的版本,剩下的就是被抹掉的那部分。所以结构元尺寸直接决定了提取的缺陷尺度上限:结构元半径 5,只能提取半径小于 5 的亮缺陷,更大的缺陷会被开运算保留下来,减完就没了。这也是为什么顶帽适合检“细缺陷”,不适合检“大缺口”,大缺口要靠 3.2 里的面积判据。

seTop = strel('disk', 5); % 只提取半径<5的亮细节 tophatImg = imtophat(gray, seTop); % 顶帽:亮缺陷(麻点、亮划痕) bothatImg = imbothat(gray, seTop); % 底帽:暗缺陷(暗裂、凹坑) defectMask = imbinarize(tophatImg, 0.06) | ... imbinarize(bothatImg, 0.06); % 双通道合并 defectMask = bwareaopen(defectMask, 12); % 去掉<12像素的孤立噪点

imtophat的第二个参数是结构元,不是阈值,这一点常被误用。阈值在后面的imbinarize里给,0.06 是归一化灰度下的门限,对应原始灰度约 15 级。bwareaopen的面积阈值 12 用来滤掉单像素噪声,但它同时会滤掉真正的微小缺陷,所以这个值要跟缺陷的最小验收尺寸对齐:如果标准要求检出 0.1 mm² 的崩边,就要根据像素当量换算成像素面积,不能凭感觉填。

顶帽和底帽的阈值不宜设得太低,否则药片本身的纹理起伏会被当成缺陷;也不宜设得太高,浅划痕会漏检。常见的做法是对顶帽图做一次高斯平滑再二值化,平滑的 σ 取 1 左右,既能压掉传感器噪声,又不会把 2 到 3 像素宽的划痕抹平。

4. 边缘算法:Canny 的四步流水线为什么对药片裂纹更敏感

4.1 梯度算子对比:Sobel、Prewitt、Roberts 的卷积核差异

边缘检测的本质是找灰度突变。一阶梯度算子用两个方向的卷积核分别卷原图,再合成梯度幅值。Sobel 的核是:

Gx = [-1 0 1; -2 0 2; -1 0 1],Gy = [-1 -2 -1; 0 0 0; 1 2 1]

Prewitt 把中心权重从 2 改成 1,平滑性弱一些,对噪声更敏感。Roberts 用 2×2 的交叉核,定位精度高但抗噪最差。三种算子的差别不在“能不能找到边”,而在“找到的边有多粗、对噪声有多宽容”。

算子核尺寸平滑权重定位精度抗噪能力药片场景适用性
Roberts2×2无高低仅适合干净仿真图
Prewitt3×3均匀中中对细裂纹略好
Sobel3×3中心加权中较高通用首选
Canny多步高斯高高裂纹、崩边检测

Sobel 中心权重为 2,相当于在求导之前先做了一次 1-2-1 的平滑,这是它比 Prewitt 抗噪好的原因。但它输出的边缘仍然是一像素宽都不到的“粗边”,需要后续细化。药片裂纹往往只有 1 到 2 个像素宽,Sobel 卷出来的梯度幅值在裂纹两侧形成一对双峰,直接二值化会得到两条平行线,反而把一条裂纹变成两条。

4.2 非极大值抑制与双阈值滞后:Canny 的两道筛子

Canny 在 Sobel 的基础上加了三步:高斯平滑、非极大值抑制、双阈值滞后连接。高斯平滑的 σ 决定了对细节的取舍,σ 大则平滑狠、小裂纹丢失,σ 小则噪声保留、伪边缘增多。非极大值抑制沿着梯度方向比较相邻像素,只保留局部最大值,把 Sobel 的粗边削成一像素宽的细边。双阈值滞后则是给边缘分等级:高于高阈值的点直接定为强边缘,低于低阈值的点直接丢弃,介于两者之间的弱边缘只有和强边缘连通时才保留。

滞后连接是 Canny 最容易被低估的一步。它的作用是让边缘“长”出来:一条裂纹只要有一小段梯度足够强,整条裂纹就能通过弱边缘的连通性被完整串起来,不会因为中间某处对比度低而断成几截。这对药片裂纹检测很关键,裂纹在药片弧面上的对比度本来就是变化的,没有滞后连接,检测结果会碎成一堆短线段,圆度、长度这些特征全都没法算。

sigma = 1.4; % 高斯平滑尺度,裂纹越细取值越小 graySm = imgaussfilt(gray, sigma); % 显式平滑,便于观察中间结果 [gx, gy] = imgradientxy(graySm, 'sobel'); [gmag, gdir] = imgradient(gx, gy); % 梯度幅值与方向 % 双阈值:[低 高],高阈值约为幅值最大值的20% bwEdge = edge(graySm, 'Canny', [0.08 0.20], sigma);

edge函数内部已经包含平滑,第四个参数sigma会再平滑一次,所以显式imgaussfilt只用于调试观察,实际检测时不要重复平滑,否则裂纹会被抹两次。[0.08 0.20]是归一化阈值,对应梯度幅值占最大值的比例。低阈值定得太高,裂纹中段会断;定得太低,药片轮廓上的纹理全被拉进来。常见做法是先跑一遍看边缘图,再以“裂纹连续、背景干净”为标准反推阈值。

4.3 形态学边缘:膨胀减腐蚀的另一种思路

除了梯度算子,形态学也能造边缘。二值图的外边缘等于膨胀结果减去原图,内边缘等于原图减去腐蚀结果,两者取并集就是完整的形态学边缘。这种边缘的宽度由结构元尺寸决定,结构元越大,边缘越粗,但边缘的闭合性比 Canny 好:Canny 在弱对比处会断,形态学边缘只要目标本身是闭合的,边缘就一定闭合。

seEdge = strel('disk', 1); % 半径1,边缘宽度约2像素 outerEdge = imdilate(bw, seEdge) & ~bw; % 外边缘 innerEdge = bw & ~imerode(bw, seEdge); % 内边缘 morphEdge = outerEdge | innerEdge; % 合并

imerode和imdilate作用在二值掩膜上,得到的是目标边界带,不是灰度梯度意义上的边。它的优势是稳定、参数少,适合做“药片轮廓是否完整”的判断;劣势是对灰度变化不敏感,药片内部的浅划痕它完全看不到。所以形态学边缘常用来做有无检测和外形完整性检测,Canny 用来做表面裂纹检测,两者分工而不是互相替代。

5. 避坑与排查:药片检测线上最容易翻车的五个细节

5.1 反光斑点被当成缺陷

现象:检测结果里每片药片都报出若干“缺陷”,位置集中在药片弧面高光处,换一批药片位置漂移。

原因:环形光在糖衣片弧面上形成镜面反射,局部灰度饱和到 255,顶帽变换把它当成比周围亮的细节提取出来。反光斑点的尺度不固定,单纯靠面积过滤不掉。

解决:在顶帽之前先做高光抑制,把灰度大于 245 的像素用邻域中值替换,或者用imfill加形态学闭运算把饱和区压平。更根本的办法是改用漫射光源或偏振片,从成像端消掉镜面反射,算法端的补救永远有残留。

5.2 粘连药片导致计数错误

现象:bwlabel数出来的药片数量比实际少,面积统计出现一个特别大的连通域。

原因:两片药片在画面里贴在一起,二值化后连成一块。开运算能断开细连接,但两片药片如果是面接触,连接处宽度可能大于结构元直径,开运算断不开。

解决:用距离变换加分水岭。先对二值掩膜做bwdist,距离变换的局部极大值对应每片药片的中心,以这些极大值为种子做分水岭分割。种子的最小间距要按药片直径设置,否则一片药片会被切成两半。

D = bwdist(~bw); % 距离变换 D = -D; % 取负,极大值变极小值 D(~bw) = -Inf; % 背景强制为极小 L = watershed(D); % 分水岭分割 L(~bw) = 0; % 只保留前景区域

5.3 结构元尺寸选错,缺陷被“抹平”

现象:顶帽图上一片干净,肉眼可见的划痕在检测结果里完全没有。

原因:结构元半径大于缺陷宽度,开运算把划痕当成“小细节”抹掉了,顶帽减完自然什么都没有。另一种情况是结构元太大,把药片边缘的崩边也一起保留,崩边反而漏检。

解决:结构元半径取“最小待检缺陷尺度”的 1.5 到 2 倍,不是药片尺寸的比例。先用已知缺陷样本做标定:画一条 3 像素宽的模拟划痕,逐步调结构元半径,找到顶帽响应最强的那个值。这个值一旦确定,同一批产品不要随意改。

5.4 边缘断裂让圆度计算失真

现象:完整药片的Circularity只有 0.6 左右,远低于理论值,导致大量误报。

原因:Canny 在药片弧面低对比处断边,regionprops算周长时把断裂处的缺口也算进去,周长偏大、圆度偏小。圆度对边缘连续性极其敏感,断几个像素就能把指标拉垮。

解决:不要直接用 Canny 结果算圆度,改用形态学边缘或者对 Canny 结果做一次闭运算桥接断口。桥接的结构元半径取 2 到 3 像素,太大把相邻边粘在一起,太小断口接不上。

5.5 批量换批后阈值漂移

现象:同一套参数,A 批药片检出率 99%,B 批药片误报率飙到 30%。

原因:不同批次的糖衣颜色、光泽度有差异,直方图整体平移,固定阈值不再适用。Otsu 虽然自适应,但它对双峰形态敏感,B 批药片的直方图谷底变浅,阈值直接偏到背景一侧。

解决:把阈值从固定值改成基于分位数的自适应阈值,比如取前景灰度分布的 5% 分位作为暗缺陷门限、95% 分位作为亮缺陷门限,每批重新统计。同时加一道人工复核机制:当某批的检出率偏离历史均值两个标准差时,暂停自动判定,转人工抽检。

6. 检测策略的取舍:边缘与区域,谁该做第一道判断

药片检测走到最后,绕不开一个选择:先做区域分割再找缺陷,还是先做边缘检测再判断闭合性。两条路的适用场景差别很大,选错方向,后面调参全是无用功。

判据区域法(阈值+形态学)边缘法(Canny+滞后)
擅长有无检测、面积、缺角裂纹、崩边、轮廓完整性
对光照敏感,需背景校正较敏感,但双阈值有容错
参数数量少(阈值、结构元)多(σ、双阈值)
边缘连续性天然闭合依赖滞后连接
计算量低中

我的习惯是把区域法放在第一道:先用 Otsu 加形态学把药片从背景里分出来,拿到每片的位置、面积、圆度,完成有无检测和缺角初筛;再对每个药片 ROI 内部跑 Canny 和顶帽,做表面裂纹和麻点检测。这样分工的好处是,边缘检测只在小 ROI 上跑,背景反光和铝箔边缘根本进不了 Canny 的输入,误报源头被提前掐掉。如果反过来先跑全图 Canny,光是把背景边缘滤掉就要花掉一半的调参时间。

验证一套参数是否稳,我一般不看单张图的检测结果,而是看三件事:连续 200 片同一批产品的面积标准差是否小于均值的 5%;换批后误报率是否在一天内收敛;以及把已知缺陷样本混进正常品里,漏检数是否为零。这三条过了,参数才算可用。只盯着测试图上的漂亮边缘图,到了产线上大概率翻车。

从那以后我每次调药片检测参数,都强制先跑一遍背景校正和形态学分割,确认二值掩膜的边缘没有缺口,再去碰 Canny 的阈值。这个顺序帮我省掉了大量“边缘明明很清晰、结果却不对”的排查时间。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进