简介:面向学习图像处理与机器视觉的读者,这套 MATLAB 药片缺陷和有无检测资料围绕工业药品视觉检测场景展开,重点解决噪声干扰、复杂背景以及目标有无与好坏判断等问题。资源共 15 个文件,包含 5 个 m 脚本、7 张 jpg 示例图以及 doc/docx 说明文档,压缩包约 1.78MB,便于结合代码、样图与文字材料对照理解。方案中涉及小波去噪抑制高斯噪声、自适应中值滤波处理椒盐噪声,并通过形态学重构去除复杂背景,再利用边缘算子和二值化提取目标,最后扫描匹配并判定药片缺陷与有无。已有 1578 人学习,适合希望掌握 MATLAB 形态学与边缘算法落地流程、复现检测思路并积累课程设计或项目案例的读者参考。
1. 从一条泡罩包装线说起:药片的缺陷和有无检测到底在解决什么
泡罩板封好铝箔、装盒之前,那一瞬间是最容易出事的地方。某个坑位空着,下游封口、装盒、装箱,等抽检查出来,一整批都要拆。药片的缺陷和有无检测这套 MATLAB 工程解决的就是这一个工位:拍一张泡罩板图,先判“这个坑位有没有片”,再判“有片的话边缘是不是完整的、有没有缺口崩边划痕”。整条链路靠形态学做分割、靠边缘算法做缺陷复核,没有网络、没有训练、没有 GPU,只要有 Image Processing Toolbox 就能跑。
它适合两类人。一类是手上有相机和 MATLAB 授权、想把视觉链路先跑通再谈上线的开发者,因为从灰度化到判定的每一步都摊在源码里,出问题能一行一行查,不是黑匣子。另一类是拿它当形态学与边缘检测练习样例的人,参数化做得比较干净,改几个阈值就能观察到结果怎么变。下面按“怎么搭的、怎么跑的、哪里会翻车、怎么改成自己的”顺着拆。
2. 技术选型与整体架构:一条六段式图像处理管线怎么搭
2.1 为什么这个场景先不急着上深度学习
药片检测听起来像是很典型的“上个小网络就完了”的场景,但真到工位上会发现三件事把这条路堵得挺死。第一是样本量,产线上正常片要多少有多少,缺陷片往往要人工挑几个月才凑够几百张,还分崩边、缺口、划痕、双片、空位等好几类,每类分下来样本薄得可怜,训出来的模型对没见过的那类缺陷基本没有泛化能力。第二是可解释性,判定 NG 之后工艺人员会问“凭什么”,一个分数 0.73 没法回答,但“这个坑位连通域面积 180 像素,低于下限 900”能立刻定位到是空位还是分割失败。第三是现场调参,换一批药片、换一种包材,光源和对比度就变了,传统链路的做法是改两个阈值重跑,模型的做法是重新标注入重新训。
所以这套工程的技术路线定得很明确:整板定位用几何网格,目标分割用形态学,缺陷判定用边缘特征加形状特征,深度学习方法留着当二期候选。这个决策背后其实是一句大白话——先把“能不能稳”跑出来,再谈“准不准”。形态学里最吃重的三个算子是大津法二值化imbinarize、开闭运算imopen/imclose、连通域分析regionprops;边缘部分用 Canny,再用凸包减原图算出凹区面积衡量缺口。整套流程串起来是六段:采集 → 预处理 → 分割 → 槽位定位 → 判定 → 输出可视化。
这个顺序不能随便动。预处理没做干净就二值化,后面形态学的结构元素尺寸怎么调都是玄学;槽位定位放在分割之后而不是之前,是为了不被光照不均带偏。
2.2 六段管线各自的输入输出契约
把每一段都写成独立的函数,函数之间只通过矩阵和结构体传递,是这套代码最先立下的规矩。这么做不是为了好看,是因为调参时会反复单独跑某一段,如果所有逻辑塞在一个脚本里,改一个阈值就要从头跑到尾,调一次两分钟,一天下来调不了几次。
各段的契约大致是这样:
| 阶段 | 入口函数 | 输入 | 输出 | 关键参数 |
|---|---|---|---|---|
| 采集 | loadImage | 文件路径 | 单通道 / 三通道矩阵 | 无 |
| 预处理 | preprocess | 原始图 | double 归一化灰度图 | 背景半径、去噪窗口 |
| 分割 | segmentChip | 预处理灰度 | 二值 mask | 自适应灵敏度、最小面积 |
| 槽位定位 | gridROI | 图像尺寸 + 行列数 | ROI 矩形数组 | 行列数、边距比 |
| 判定 | judgePresence/judgeDefect | mask + ROI | 状态字符串 + 特征结构体 | 面积上下限、凸度阈值 |
| 输出 | renderResult | 原图 + 判定表 | 标注图 + 表格 | 无 |
契约一旦定下来,后面所有“为什么这里要这样”的讨论都有了落点。比如preprocess返回的一定是 0 到 1 的 double,不是 uint8,因为imbinarize的自适应模式对 double 的灵敏度参数响应更线性,uint8 下同一个灵敏度值在不同曝光下表现差别很大。这类细节不写死在契约里,早晚会在批处理时翻车。
2.3 目录结构与函数职责
工程目录按职责分层,main_inspect.m只做编排,不写算法;config/params.m集中所有可调参数,改参数只改一个文件;src/下按管线阶段拆函数。实际结构是这样:
pill_inspect/ ├── main_inspect.m % 单图入口,负责调用链编排 ├── main_batch.m % 批量入口,遍历目录写结果表 ├── config/ │ └── params.m % 所有阈值、尺寸、路径集中在这里 ├── src/ │ ├── loadImage.m % 读图 + 通道归一 │ ├── preprocess.m % 顶帽 + 去噪 + 对比度拉伸 │ ├── segmentChip.m % 二值化 + 形态学清理 │ ├── gridROI.m % 按行列均分生成槽位 ROI │ ├── judgePresence.m % 有无判定 │ ├── judgeDefect.m % 缺陷判定(边缘 + 形状) │ ├── renderResult.m % 画框、打标签、存图 │ └── px2mm.m % 像素/毫米换算,阈值全部走它 └── data/ ├── good/ % 良品样本 └── ng/ % 缺陷样本px2mm.m这个看起来最不起眼的文件其实最关键。所有结构元素半径、面积上下限、边缘带宽,在配置里一律以毫米写,进函数时统一换算成像素。理由是换相机换镜头是产线上最常发生的事,分辨率一变,原来调好的像素阈值全部失效,而毫米阈值不受影响。我见过不止一个项目在换相机之后整条链路全崩,最后发现只是结构元素半径从 15 像素变成了 15 个“新像素”。
3. 图像预处理链路:灰度化、光照不均校正与去噪的先后顺序
3.1 通道选择:别急着 rgb2gray
拿到一张彩色图,第一反应是rgb2gray,这在大多数场景没问题,但在透明泡罩加铝箔反光的组合下会吃掉关键对比度。rgb2gray内部是加权求和,权重约 0.2989R + 0.5870G + 0.1140B,绿色权重最大,因为人眼对绿最敏感——可相机的 Bayer 排列和产线光源未必按人眼来。如果光源是白光 LED、包材偏蓝,蓝色通道里药片和背景的灰度差可能比加权后的灰度图大得多。
工程上的做法是先各通道看一眼对比度再决定,配置文件里留一个ChannelMode开关:
function gray = loadImage(imgPath, channelMode) %LOADIMAGE 读图并归一化为单通道 double(0~1) % imgPath : 图像文件路径 % channelMode : 'gray' | 'R' | 'G' | 'B' | 'max' | 'sat' raw = imread(imgPath); if ndims(raw) == 3 switch channelMode case 'gray' g = rgb2gray(raw); case 'R' g = raw(:,:,1); case 'G' g = raw(:,:,2); case 'B' g = raw(:,:,3); case 'max' g = max(raw, [], 3); % 取最亮通道,压反光 case 'sat' hsv = rgb2hsv(raw); g = hsv(:,:,2); % 用饱和度区分药片与白色铝箔 end else g = raw; % 已经是灰度图,直接透传 end gray = im2double(g); end这里ndims(raw) == 3那个分支必须写。批处理目录里经常混着灰度图和彩色图,早期版本直接写raw(:,:,3),跑到某一张就报数组越界,整批任务中断,前面跑的全白费。'sat'这个模式是后来加的:糖衣片有颜色、铝箔是白的,饱和度图上药片区域明显更亮,比灰度图抗反光。
参数上要注意'max'和'sat'都改变了灰度分布,后面的自适应灵敏度要重新标定,不能沿用灰度图的 0.45。
3.2 光照不均校正:顶帽变换怎么用才不糊
环形光源打下来,视场中心亮、边缘暗,这是几乎躲不掉的。直接对全图做一次大津法,阈值只能取一个,中心区域药片被完整分割,边缘区域整片糊成一块背景,槽位全判空位。这就是典型的“预处理没做干净就去二值化”。
顶帽变换是这里最省事的一招。原理不复杂:开运算会把比结构元素小的亮目标抹掉,只留背景,那原图减开运算结果,剩下的就是比背景亮的局部目标。结构元素半径的选择是这个函数唯一的调参点——它必须大于药片本身的最大半径,否则药片被当成背景一起减掉;同时又必须小于光照渐变的尺度,否则背景没被抹干净,减完还是不均。
function ig = flattenIllumination(gray, bgRadiusPx) %FLATTENILLUMINATION 顶帽变换压掉背景光斑与镜头暗角 % gray : 单通道 double,0~1 % bgRadiusPx : 背景结构元素半径(像素),须大于药片最大外接半径 seBg = strel('disk', bgRadiusPx); opened = imopen(gray, seBg); % 开运算:抹掉亮目标,只留背景 ig = imsubtract(gray, opened); % 原图减背景,得到均匀化的目标 ig = imadjust(ig); % 重新拉满动态范围 endimsubtract之后动态范围被压缩了,最亮的目标也就零点几,imadjust不拉一次,后面imbinarize的灵敏度参数完全没有意义。这个顺序在不少教程里被省掉了,结果就是同样一个灵敏度,换一张图效果天差地别。
半径怎么定?我一般先量药片直径,bgRadiusPx = round(px2mm(药片直径mm) * 0.75),再用一张边缘区域的样图肉眼确认开运算后药片是否完全消失。如果药片还在,说明半径不够;如果背景里的浅色条纹也被一起抹掉,说明半径太大,需要往下调。
提示:顶帽变换只对“亮目标暗背景”有效。如果光源是背光、药片在亮背景上呈暗色,要用底帽变换
imbothat,两者不能混用。
3.3 去噪与对比度拉伸的先后顺序
药片表面有压片麻点,铝箔上偶尔有碎屑,相机本身有传感器噪声。这些噪声对形态学的影响比对边缘算法还大——一个孤立噪点在二值化后会变成一个小连通域,开运算能去掉,但如果噪点恰好贴着药片边缘,就会把药片轮廓咬出一个假缺口,直接判 NG。
去噪放在顶帽之后、二值化之前。顺序反过来也能跑,但顶帽的结构元素会把噪声点也一起放大,压在后面的滤波要把窗口开得很大,边缘一起糊掉。中值滤波和高斯滤波二选一,判断依据是噪声类型:压片麻点、铝箔碎屑这类椒盐噪声用中值,传感器噪声这类高斯噪声用高斯。
function id = denoiseChip(i, mode, winSize, sigma) %DENOISECHIP 去噪,按噪声类型切换滤波器 % i : 单通道 double % mode : 'median' 压片麻点/碎屑 | 'gauss' 传感器噪声 % winSize : 中值滤波窗口边长,奇数,常用 3 或 5 % sigma : 高斯核标准差,常用 1.0~1.5 switch mode case 'median' id = medfilt2(i, [winSize winSize], 'symmetric'); case 'gauss' id = imgaussfilt(i, sigma, 'FilterSize', 2*ceil(3*sigma)+1); otherwise error('未知去噪模式: %s', mode); end endmedfilt2的第三个参数'symmetric'是边界处理方式,用对称填充替换默认的零填充。零填充会在图像四边造出一圈黑边,如果泡罩板贴边拍摄,边缘槽位会被这圈黑边影响,判出莫名其妙的 NG。这个坑我在第一次跑满板图时就踩了,板子右下角两个槽位永远报缺陷,查了半天发现是补零边界。
对比度拉伸放在去噪之后。如果先做imadjust再做中值滤波,拉伸会把噪声对比度也放大,中值窗口得跟着变大,药片边缘跟着钝。imadjust之后如果还想再强一点,可以用adapthisteq(限制对比度自适应直方图均衡),但它对噪声非常敏感,必须严格放在去噪之后,且ClipLimit不要超过 0.01。
4. 形态学与边缘算法核心:从二值化到缺陷判定的落地
4.1 二值化与形态学开闭:把药片从背景里抠出来
二值化这一步,全局大津法在光照均匀时够用,但实际产线上顶帽变换之后仍然会有局部明暗残留,全局阈值会漏掉一部分边缘槽位。用自适应模式更稳:
function [mask, bwRaw] = segmentChip(gray, sens, minAreaPx, seOpenPx, seClosePx) %SEGMENTCHIP 自适应二值化 + 形态学清理 % gray : 预处理后的单通道 double % sens : 自适应灵敏度,0~1,越大越容易判为前景 % minAreaPx : 最小保留面积(像素²),滤掉碎屑 % seOpenPx : 开运算半径(像素),去毛刺 % seClosePx : 闭运算半径(像素),补缺口 bwRaw = imbinarize(gray, 'adaptive', ... 'ForegroundPolarity', 'bright', ... 'Sensitivity', sens); % 开运算:先腐蚀掉贴着轮廓的毛刺和细碎噪点 mask = imopen(bwRaw, strel('disk', seOpenPx)); % 闭运算:再膨胀回来,填平药片内部的小孔洞 mask = imclose(mask, strel('disk', seClosePx)); % 面积滤波:小于最小面积的连通域直接丢弃 mask = bwareaopen(mask, minAreaPx); % 填洞:药片表面的刻字凹槽会在 mask 里留洞 mask = imfill(mask, 'holes'); end几个参数的含义值得说清楚。Sensitivity在自适应模式下不是阈值本身,而是控制局部阈值相对局部均值的偏移量,越大越倾向于把像素判成前景。药片反光强的时候这个值要往下调,糖衣片偏暗的时候要往上调,实测下来 0.4 到 0.6 之间能覆盖大部分情况。
开运算和闭运算的顺序不能反。先开后闭是先去掉外轮廓的毛刺再补内部孔洞;反过来先闭后开,闭运算会把两个靠得近的药片连成一片,开运算再也分不开。这个顺序问题在药片间距小的时候尤其明显。
imfill(mask,'holes')这一句是必须的。药片上的刻字、糖衣的凹坑在二值化后会变成内部孔洞,不填掉的话,regionprops算出来的面积偏小,本来合格的药片会被判成面积不足。这个坑很隐蔽,因为肉眼看 mask 图几乎看不出洞,只有面积数值对不上才发现。
4.2 有无检测:网格 ROI 加面积区间判定
有无检测和缺陷检测最好拆成两套逻辑,因为它们的失败模式完全不同。有无检测要回答的是“这个坑位该不该有东西”,缺陷检测要回答的是“这个东西本身完不完整”。合在一起写,一个空位会被判成“100% 缺失的缺陷”,报错信息就没法看了。
槽位定位不做全图连通域,用几何网格。理由是全图连通域在铝箔反光、泡罩破损时会粘连,一粘就少一个槽位,整板判定错位。几何网格的前提是相机固定、板子定位靠机械挡块,实际产线上这个前提基本成立。
function rois = gridROI(imgSize, rows, cols, marginRatio, shrinkRatio) %GRIDROI 按行列均分生成槽位 ROI % imgSize : [H W] % rows, cols : 泡罩板行列数 % marginRatio : 四周留白比例,避开板边 % shrinkRatio : 单槽收缩比例,避免吃到相邻槽位 H = imgSize(1); W = imgSize(2); mh = round(H * marginRatio); mw = round(W * marginRatio); cellH = (H - 2*mh) / rows; cellW = (W - 2*mw) / cols; rois = struct('Row', {}, 'Col', {}, 'Rect', {}); k = 0; for r = 1:rows for c = 1:cols k = k + 1; x0 = round(mw + (c-1)*cellW + cellW*(1-shrinkRatio)/2) + 1; y0 = round(mh + (r-1)*cellH + cellH*(1-shrinkRatio)/2) + 1; w = floor(cellW * shrinkRatio); h = floor(cellH * shrinkRatio); rois(k).Row = r; rois(k).Col = c; rois(k).Rect = [x0, y0, w, h]; % [x y w h],imcrop 约定 end end endRect用的是imcrop的[x y w h]约定,不是 MATLAB 索引的[row col],这点一定要在注释里写死,否则后面取 ROI 的时候行列颠倒,切出来的全是横条,排查起来很费时间。
有了 ROI,有无判定就是切出来数面积:
function [state, areaPx] = judgePresence(mask, roi, areaLowPx, areaHighPx) %JUDGEPRESENCE 单槽位有无判定 % areaLowPx : 面积下限,低于判空位 % areaHighPx : 面积上限,高于判粘连/双片 sub = imcrop(mask, roi); areaPx = nnz(sub); if areaPx < areaLowPx state = "MISSING"; % 空位 elseif areaPx > areaHighPx state = "MULTI"; % 粘连或双片 else state = "PRESENT"; % 正常有片,进入缺陷判定 end end面积上下限从毫米换算:正常片面积是 πr²,下限取 0.6 倍,上限取 1.5 倍。这两个系数看起来宽松,但实际调的时候宁可宽一点——过窄会让稍微偏位的正常片被判空位,那是最不能接受的误检。
4.3 边缘算法判缺陷:Canny 边缘加凸度、圆度双指标
有片之后的缺陷判定,靠三个特征投票:圆度、凸度(凸包减原图的凹区比例)、Canny 边缘密度。单靠任何一个都不稳。
圆度用4πA/P²手算,正常圆片接近 1,崩边之后周长变大、面积变小,圆度明显下降。凸度用bwconvhull生成凸包,减去原 mask 就是凹进去的部分,缺口、崩边都落在这块区域里。Canny 边缘图主要用于两件事:一是给出可视化的边缘叠加图给人工复核,二是统计药片内部的边缘密度,划痕会在内部产生额外的边缘。
function [ok, feat] = judgeDefect(grayChip, maskChip, p) %JUDGEDEFECT 单颗药片缺陷判定 % grayChip : 药片外接矩形内的灰度子图 % maskChip : 同尺寸的二值 mask % p : 参数结构体,见 config/params.m feat = struct(); feat.Area = nnz(maskChip); % --- 圆度:4*pi*A/P^2 --- st = regionprops(maskChip, 'Perimeter'); per = st(1).Perimeter; feat.Circularity = 4*pi*feat.Area / max(per^2, eps); % --- 凸度凹区:凸包减原图,拿凹进去的面积占比 --- hull = bwconvhull(maskChip); concave = hull & ~maskChip; feat.ConcaveRatio = nnz(concave) / max(feat.Area, 1); % --- Canny 边缘:只保留药片内部,排除外轮廓干扰 --- inner = imerode(maskChip, strel('disk', p.EdgeBandPx)); e = edge(grayChip, 'Canny', p.CannyThresh, p.CannySigma); e = e & inner; feat.EdgeDensity = nnz(e) / max(nnz(inner), 1); % --- 三指标投票 --- badCircle = feat.Circularity < p.CircLow; badConcave = feat.ConcaveRatio > p.ConcaveHigh; badEdge = feat.EdgeDensity > p.EdgeHigh; ok = ~(badCircle || badConcave || badEdge); endedge的 Canny 模式里,第三个参数是阈值,可以给标量也可以给[low high]双阈值,第四个是高斯平滑标准差 sigma。sigma 越大越抗噪、越吃细边缘;划痕这类缺陷本身就细,sigma 超过 1.5 基本就没了。我一般从 1.0 起调,阈值用[0.08 0.20]这种偏低的组合,宁可多检出一些边缘,靠后面的EdgeDensity阈值去卡。
EdgeBandPx这个参数是腐蚀带宽,作用是把药片外轮廓那一圈边缘剔掉。不剔的话,外轮廓本身就是一圈强边缘,EdgeDensity的基数被抬高,划痕带来的增量被稀释掉,阈值怎么调都不敏感。这个参数一般取 3 到 5 像素,太小剔不干净,太大把靠近边缘的崩边也一起剔了。
三个阈值CircLow、ConcaveHigh、EdgeHigh没有通用值,只能拿良品样本跑一遍,把三个特征的分布打出来,取下限或上限往外放一点。这部分工作没什么捷径,属于血泪经验——凭感觉定阈值上线,早晚要被一批没见过的包材教做人。
5. 避坑与排查:跑不通、误检、漏检的五条实战记录
5.1 预处理与分割阶段的三个坑
坑一:换一批糖衣片后几乎每片都判 NG。
现象很直观,良品批跑出来满屏红框,凹区比例全部超标。原因是糖衣片表面反光比素片强得多,顶帽变换之后药片中心过曝,边界向外扩了一圈,但扩得不均匀,凸包减原图算出来一大片假凹区。更麻烦的是过曝区域的灰度和背景拉不开,自适应二值化把它吞掉了半个。
解决分两步:先降曝光或者换漫射光源,把药片表面的高光压下去;再在judgeDefect之前对 mask 做一次小半径开运算去毛刺,strel('disk', 3),把外扩的锯齿边磨平。同时把Sensitivity从 0.45 提到 0.55,让过曝区域也能被判进前景。这三处一起改之后,糖衣片的误检基本压住了。
坑二:右下角两个槽位永远报缺陷,换了图也一样。
现象是位置固定,跟药片内容无关。原因是中值滤波默认零填充,在图像右下边缘造出一圈黑边,黑边和药片之间的过渡带被 Canny 抓成强边缘,EdgeDensity直接爆表。
解决是把medfilt2的填充方式改成'symmetric',或者在所有滤波之前先padarray扩一圈再裁回来。前者更省事,改一个参数就行。这个坑的隐蔽之处在于它只影响贴边区域,如果你的泡罩板四周留白足够,可能永远碰不到;一旦改成满幅拍摄就立刻暴露。
坑三:两颗挨得近的药片被判成一片,另一个槽位报空位。
现象是相邻两个槽位,一个面积超标判MULTI,一个面积不足判MISSING。原因是铝箔反光把两个槽位的 mask 连成了一条细桥,bwareaopen滤不掉,因为桥连着的是一大块。
解决方向是别依赖全图连通域,按 ROI 切分之后再判面积,桥被 ROI 边界切断,两个槽位各算各的。如果 ROI 切完还是粘,可以在 ROI 内先做一次距离变换bwdist加分水岭watershed,把粘连目标分开。分水岭容易过分割,只在 ROI 内小范围用,别上全图。
5.2 判定与工程化阶段的两个坑
坑四:细划痕在 Canny 边缘图上完全消失。
现象是肉眼能看到一道浅划痕,但EdgeDensity和良品没差别。原因是 Canny 的 sigma 用了 1.8,划痕宽度只有两三个像素,被高斯平滑直接抹平了。同时双阈值取的是[0.15 0.35],弱边缘全被抑制。
解决是把 sigma 降到 0.8 到 1.2,双阈值降到[0.06 0.18],把弱边缘尽量保留。但这样噪声也会进来,所以EdgeDensity阈值要跟着往上提。划痕这类低对比度缺陷,更稳的做法是先做底帽变换imbothat,结构元素半径取比划痕宽度略大,把暗细线从药片表面提出来,再单独统计底帽图的响应强度,不跟 Canny 混在一起判。
坑五:换了个分辨率更高的相机,全部阈值失效。
现象是结构元素半径还是按老分辨率的像素值写,开运算直接把药片腐蚀没了,mask 全空。这个坑在前面提过一次,这里说具体的处理方式。
解决是引入px2mm换算层,配置里所有尺寸一律写毫米,进函数时换算。同时加一个启动自检:跑第一张图时输出“当前分辨率 xx px/mm,药片等效直径 xx 像素,结构元素半径 xx 像素”,跟预期对不上就直接报错退出,别让它默默跑完整批。这个自检大概十行代码,省下的排查时间是以天计的。
注意:批量跑之前一定要先跑一张带自检的样图。批处理跑了一半崩掉,前面写出的结果文件是不完整的,如果下游直接拿这份表做统计,错误会被带进下一道工序。
6. 二次开发:把脚本改成能落地的小工具
拿到这套代码之后最该做的第一件事,是把散在函数里的硬编码参数全部收进config/params.m,用一个结构体传下去。原来的写法里,自适应灵敏度、结构元素半径、面积上下限各写在各处,调一次参要翻五个文件。收拢之后长这样:
function p = params(pxPerMM) %PARAMS 全部可调参数,尺寸类统一以毫米给出并按分辨率换算 p.PxPerMM = pxPerMM; p.ChannelMode = 'gray'; p.BgRadiusPx = round(3.2 * pxPerMM); % 药片最大半径约 3.2mm p.DenoiseMode = 'median'; p.DenoiseWin = 5; p.Sens = 0.45; p.OpenPx = round(0.25 * pxPerMM); p.ClosePx = round(0.30 * pxPerMM); p.MinAreaPx = pi * (2.0*pxPerMM)^2; % 面积下限按等效半径换算 p.SlotAreaLow = p.MinAreaPx * 0.60; p.SlotAreaHigh = p.MinAreaPx * 1.50; p.CannyThresh = [0.08 0.20]; p.CannySigma = 1.0; p.EdgeBandPx = round(0.35 * pxPerMM); p.CircLow = 0.82; p.ConcaveHigh = 0.08; p.EdgeHigh = 0.16; end第二步是把批处理入口写扎实,遍历data/下所有图,逐张跑,结果落成一张表。表里除了判定结果,把三个特征值也一起写进去,后面调阈值时直接看这列数据的分布,比重新跑一遍快得多。
function tbl = main_batch(cfg) %MAIN_BATCH 批量检测,输出每张图每个槽位的判定与特征 files = [dir(fullfile(cfg.dataDir, '*.png')); ... dir(fullfile(cfg.dataDir, '*.jpg'))]; rows = {}; for i = 1:numel(files) fp = fullfile(files(i).folder, files(i).name); gray = loadImage(fp, cfg.p.ChannelMode); pre = preprocess(gray, cfg.p); mask = segmentChip(pre, cfg.p.Sens, cfg.p.MinAreaPx, ... cfg.p.OpenPx, cfg.p.ClosePx); rois = gridROI(size(mask), cfg.Rows, cfg.Cols, 0.04, 0.86); for k = 1:numel(rois) [state, areaPx] = judgePresence(mask, rois(k).Rect, ... cfg.p.SlotAreaLow, cfg.p.SlotAreaHigh); feat = struct('Circularity', NaN, 'ConcaveRatio', NaN, ... 'EdgeDensity', NaN); if state == "PRESENT" mChip = imcrop(mask, rois(k).Rect); gChip = imcrop(pre, rois(k).Rect); [~, feat] = judgeDefect(gChip, mChip, cfg.p); end rows(end+1,:) = {files(i).name, rois(k).Row, rois(k).Col, ... char(state), areaPx, feat.Circularity, ... feat.ConcaveRatio, feat.EdgeDensity}; %#ok<AGROW> end end tbl = cell2table(rows, 'VariableNames', {'File','Row','Col','State', ... 'AreaPx','Circularity','ConcaveRatio','EdgeDensity'}); writetable(tbl, fullfile(cfg.outDir, 'result.csv')); endimcrop切mask和切pre必须用同一个Rect,否则特征是在错位的图上算的。这种错位不会报错,只会让阈值调到怀疑人生。
第三步是验证。别拿跑通的图当验证集,要拿一批标注好的数据算混淆矩阵,重点看两类错误:正常片被判 NG 的误检,和缺陷片漏过去的漏检。前者影响产线节拍,后者影响质量,两个都得压。做法是把result.csv里State一列跟人工标注对一遍,用confusionchart画出来,误检集中在哪个特征上,回去单独调那个阈值。
最后说一个习惯。从那次右下角槽位永远报缺陷之后,我每次改完参数,都会先跑一张四周带留白的整板图、一张贴边拍摄的整板图、一张只有空位没有药片的图,三张全过再放进批处理。这三张覆盖了边界、满幅、毫无前景这三种最容易翻车的情况,跑一遍不到十秒。希望帮到你。
本文还有配套的精品资源,点击获取