ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

MATLAB形态学与边缘算法:药片有无及缺陷检测实践

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 像素时,半径 115,seR 取 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'); end

ClipLimit 是裁剪限幅,越小增益越保守;NumTiles 是分块数,分得越细局部对比度越强、块边界伪影也越明显。这两个参数没有普适值,但有一个判断标准:把一张良品图跑一遍,如果托盘区域出现成片的亮斑,说明增益过头了,往回压。

2.3 归一化阶段的参数表

参数作用标定方法典型取值
seR背景估计结构元半径取单片成像半径的 1.1 到 1.3 倍130 到 150 像素
useClahe是否做局部均衡良品图跑一遍看托盘有无亮斑false
ClipLimit均衡增益上限从 0.005 起调,出现块边界伪影就停0.01
NumTiles分块粒度与缺陷最小尺寸对齐,不小于 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"; end

cfg.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 到 1),high 决定「强边缘种子」,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; end

ringW 是关键参数。太小,缓冲带压不住外轮廓的抖动,良品片也会被算进边缘密度;太大,靠近边沿的崩边会被一起屏蔽掉。实测取 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); % 统一成"药片比背景亮"再走后续流程 end

5.5 结构元尺寸拍脑袋,缺陷被自己抹平

现象:调完参数后缺陷检出率骤降,肉眼能看到的崩边全被判成良品。

原因:imopen 去毛刺的半径用了 5,imclose 算崩边的 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{end+1} = {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% 以上直接报警停机,不给自己留后悔药。希望帮到你。

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

RELATED READING

延伸阅读

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