ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

MATLAB药片缺陷检测:形态学、边缘与圆拟合实战

MATLAB药片缺陷检测:形态学、边缘与圆拟合实战

简介:面向图像处理课程设计与机器视觉入门者的药片缺陷及有无检测 MATLAB 实践案例,围绕小波去噪、自适应中值滤波、形态学重构与边缘算子,搭建从预处理到目标判别的完整算法链路,可用于理解工业检测场景下的实现思路。压缩包共 15 个文件、约 1.78MB,其中 5 个 .m 脚本分别承担主流程、边界提取、帧差处理与自适应中值滤波等工作,7 张 jpg 为各阶段效果图与样例图像,2 份 doc 与 1 份 docx 给出检测说明与边缘函数讲解,图文与代码相互对照便于验证。已有 1578 人学习下载。读者可从中获得可直接运行的算法脚本、去噪与形态学重构的参数组织方式、二值化后扫描匹配与好坏有无判定的实现细节,以及配套文档中的原理梳理,适合按预处理、分割、判别的顺序逐步复现并调参。

1. 药片检测在检什么:一片崩边和一片缺角,代码得能分开

产线质检工位上摆着一台工业相机,背光板上的药片一颗颗往下走,人眼盯十几分钟就开始糊:漏掉一片崩边的,整批货要返工。这个 MATLAB 项目负责的就是把眼睛换成代码——先用形态学把药片从背光背景里剥出来,再用边缘算法量每一片的轮廓,最后判断有没有(缺片、叠片、计数对不上)和好不好(崩边、缺角、裂片、污点)。

它适合已经有一条简单成像链路、想把算法快速跑通再谈上线的开发者,也适合拿它当形态学与边缘检测的实战样例来啃。整套流程没有深度学习那一套环境依赖,一台装了 MATLAB 和 Image Processing Toolbox 的机器就能开跑。

算法本身不复杂,难的是参数。同一套阈值在实验室的样图上漂亮得不行,搬到产线上换个批次的药片就整片整片地误判——这也是后面几章要反复拆的部分。

2. 工程骨架:MATLAB 里一条从读图到判定的六段流水线

很多人拿到这类项目的第一反应是打开一个 .m 文件从头往下读,读到第三十行就迷路了:变量名全是bw1、bw2、I2,中间还夹着几次figure调试。这套代码要想改得动,第一步得先把职责分开。

2.1 目录结构:把参数、算法、入口三样东西拆开放

我一般会按下面这个结构摆:

pill_inspect/ ├── main_inspect.m % 单图调试入口:读一张图,跑完,显示 ├── main_batch.m % 批量入口:扫 ok/ng 文件夹,出混淆矩阵 ├── +pill/ % 包目录,外部以 pill.xxx() 调用 │ ├── segment.m % 图像 -> 二值掩膜 │ ├── countPills.m % 掩膜 -> 连通域标号(含粘连拆分) │ ├── measure.m % 标号 -> 每片的几何与灰度特征 │ ├── judge.m % 特征 -> OK / NG 与原因 │ ├── fitCircle.m % 最小二乘圆拟合,崩边判据的来源 │ └── showResult.m % 可视化,只在调试入口调用 ├── config/ │ └── params.m % 所有阈值,一个函数返回一个结构体 ├── data/ │ ├── ok/ % 良品样图,用来回归 │ ├── ng/ % 不良品样图 │ └── test/ % 单张调试用 └── report/ % 批量跑完的 csv 落在这一层

三个目录各有各的作用。+pill/用 MATLAB 的包目录语法,函数名不会污染全局路径,也不会跟工具箱里同名的measure、judge撞车——这个冲突我在早期版本里踩过,报错信息还特别含糊,只说你调用参数数量不对。config/params.m是整套代码里最该被重视的文件,产线换批次时改的永远是它,而不是算法函数。data/ok和data/ng则是你的回归集,改完参数必须跑一遍,不然就是凭感觉调参。

单图调试入口写出来大概是这样,它只做四件事:拿参数、读图、调用、显示。

% main_inspect.m —— 单图调试入口,不承担任何算法逻辑 clear; clc; close all; P = params(); % 阈值全部来自 config/params.m img = imread(fullfile('data','test','sample_07.png')); [bw, gray] = pill.segment(img, P); % 第一段:分割 [L, n] = pill.countPills(bw, P); % 第二段:计数与粘连拆分 props = pill.measure(L, gray, P); % 第三段:逐片量化 rep = pill.judge(props, P); % 第四段:出结论 fprintf('检出 %d 片,整体判定:%s\n', n, string(rep.pass)); pill.showResult(img, L, rep); % 产线版本里这一行删掉

P是唯一一个贯穿全流程的变量,好处是任何一次判定结果都能复现——把当时的P存下来,三个月后出了问题还能原样重跑。pill.showResult单独拎出来是有意的,imshow、montage、plot这些调用会显著拖慢单帧耗时,上线前必须能干净地摘掉,而不是靠注释一行行去找。

参数文件长这样,每一行都对应一个物理含义:

function P = params() % 所有可调阈值集中在这里,改完务必跑 main_batch 回归 P.blurSigma = 1.2; % 高斯 sigma,单位像素;越大越糊,崩边细节会被抹平 P.bgRadius = 60; % 背景估计结构元素半径,取图像短边的 1/8 左右 P.threshScale = 1.00; % Otsu 阈值缩放系数,0.90~1.10 之间微调 P.minArea = 800; % 单片最小面积(像素),小于它当噪点丢弃 P.openR = 2; % 开运算半径,去毛刺 P.closeR = 2; % 闭运算半径,补缺口;别超过最小崩边宽度的 1.5 倍 P.minCirc = 0.82; % 圆形度下限,低于它怀疑崩边或裂片 P.areaTol = 0.12; % 面积相对偏差容限 ±12% P.residTol = 1.8; % 圆拟合半径残差 std 上限(像素) P.expectNum = 24; % 治具上应有片数,用于有无检测 end

2.2 算法主链路:灰度化、压噪、去背景、二值化、形态学、连通域

整条链路其实只有六步,但每一步都有个“为什么是它而不是别的”的理由。

第一步转灰度。如果相机出的是彩色图,rgb2gray用的加权系数对背光成像基本够用;但如果药片本身是带颜色的糖衣片,颜色反而是重要信息(比如深浅片混淆),这时候就该考虑在 HSV 空间取某个通道,而不是无脑转灰度。

第二步压噪。imgaussfilt的高斯核比中值滤波更适合抑制传感器的高斯型噪声,代价是边缘变软。sigma 从 1.0 起调,崩边只有一两个像素宽的时候,sigma 上到 2 就基本判不出来了——这是最常见的一类“算法不准”投诉的真实原因。

第三步去背景不均。背光板用久了会有暗角,全局 Otsu 在这种图上会把中间一片判成前景、边上一片判成背景。老版本 MATLAB 没有imflatfield,用形态学顶帽等价实现:

% 背景估计:大半径开运算把“比结构元素窄的亮区”抹掉,剩下慢变的背景 se = strel('disk', P.bgRadius, 0); bg = imopen(gray, se); gray = gray - bg; % 顶帽变换:背景被减掉,药片边缘被保留 gray = mat2gray(gray); % 归一化到 [0,1],方便后面固定阈值

strel('disk', R, 0)第三个参数是近似方式,0 表示用多边形近似,速度快、精度略差;需要更圆的盘就写strel('disk', R, 4),代价是运算变慢。半径 R 的取法有个简单准则:必须明显大于单个药片的直径,又必须小于整幅图的光照变化尺度,一般在图像短边的 1/6 到 1/10 之间。

第四步二值化,背光成像下药片是暗的,取反让药片成为true:

level = graythresh(gray); % Otsu,直方图双峰时最稳 bw = imbinarize(gray, level * P.threshScale); bw = ~bw; % 关键一行:别忘 bw = imfill(bw, 'holes'); % 药片刻字造成的内部空洞 bw = bwareaopen(bw, P.minArea); % 去小噪点

imfill(bw,'holes')这一步容易被跳过,后果是药片中心被刻字、凹槽挖出一堆洞,后面算面积全部偏小,圆形度也崩了。

第五步形态学清理,第六步连通域标号,这两块内容多,下一章单独拆。

2.3 参数之间的耦合关系:改一个必须回头看三个

新手最容易犯的错是把参数当成互相独立的旋钮,一个个拧到“看起来对”为止。实际上它们强耦合,下面这张表是我自己调参时贴在显示器边上的备忘:

参数改动方向直接受益连带副作用需要同步复查
blurSigma调大噪点少、分割干净崩边被抹平,residTol失效threshScale、minCirc
bgRadius调大背景估计更平滑大药片被当背景减掉minArea
threshScale调大前景收缩,粘连变少薄片、边缘暗的片被切碎minArea、openR
openR调大毛刺、飞边去掉药片整体缩小,小片消失minArea、areaTol
closeR调大缺口补上,轮廓闭合相邻片粘成一片分水岭阈值、expectNum
minArea调大噪点滤得干净小尺寸规格的片被整片丢掉expectNum

举个具体例子:把openR从 2 调到 4,单片的面积会缩小大概 3% 到 6%(缩小量约等于周长乘以半径),这时候areaTol还卡在 ±12%,看起来没问题,但面积中位数已经整体下移,一旦有批次本身偏小就会集体越界。所以每次动形态学半径,我都要重跑一遍批量回归看面积分布,而不是只看单张图顺眼。

3. 形态学拆解:结构元素、开闭运算与粘连药片的分离

形态学在这个项目里不是“美化一下掩膜”的收尾步骤,它直接决定了面积、周长、圆形度这些判据准不准。半径差一个像素,崩边判据的阈值就得跟着改。

3.1 结构元素尺寸怎么由物理尺寸反推

不要凭感觉给半径,先从标定板算出像素当量。假设标定结果是 0.05 mm/pixel,药片直径 8 mm,也就是 160 像素;允许的最小崩边宽度 0.2 mm,也就是 4 像素。

按这个换算,几个半径就有了依据:

  • openR取 2,对应 0.1 mm,只能削掉比这更细的毛刺,不会伤到真正的崩边;
  • closeR取 2,能补上 0.2 mm 以内的缺口,再大就会把两片之间 0.3 mm 的缝糊死;
  • bgRadius取 60,对应 3 mm,远大于药片直径的一半,又远小于整幅视场。

这套换算还有个用处:跟工艺人员沟通时不用讲像素。对方说“崩边超过 0.2 mm 才算不良”,你直接把它翻译成 4 像素,写进params.m的注释里,后面谁改都能看懂。

strel的形状也别乱选。圆形结构元素对回转体形状的药片最自然;用方形strel('square', n)会在斜 45 度方向多削掉一块,圆形度算出来系统性偏低。异形片(椭圆片、胶囊片)另说,那种情况圆形度这个判据本身就不该用。

3.2 开运算与闭运算的顺序、半径和副作用

开运算是先腐蚀后膨胀,作用是去掉比结构元素小的亮细节(毛刺、飞边、粘连的细桥);闭运算是先膨胀后腐蚀,作用是填补比结构元素小的暗细节(缺口、裂缝、内部小洞)。

顺序上一定先开后闭。反过来先闭的话,缺口补上的同时两个相邻药片之间的窄缝也被填了,再开运算也分不开——因为缝已经变成实心区域了。

% 形态学清理:顺序不能反 bw = imopen(bw, strel('disk', P.openR)); % 1) 去毛刺、断开细桥 bw = imclose(bw, strel('disk', P.closeR)); % 2) 补缺口、闭合轮廓 bw = imfill(bw, 'holes'); % 3) 填内部空洞 bw = bwareaopen(bw, P.minArea); % 4) 最后再滤小连通域

注意bwareaopen放在了最后。如果放在闭运算之前,那些面积略小于阈值、本来会被闭运算连成一片的小碎块就被提前删掉了,等于人为制造了漏检。这个顺序问题我在一个批次上排查了大半天,最后发现是两行代码写反了。

还有一个隐蔽的坑:imclose之后掩膜边界会整体外扩约closeR个像素,如果直接拿它算面积,会比真实面积偏大 5% 左右。要么在闭运算后补一次同半径的开运算(开闭级联,imclose再imopen),要么在标定areaTol的时候把这点系统性偏差吸收进去。我选后者,因为多一次开运算又会把崩边削掉。

3.3 粘连分离:距离变换加分水岭,再按面积回合并

药片在振动盘上走,偶尔两片贴在一起。这时候连通域标号会把它们算成一片,计数少一片,面积超上限,两个判据同时报警但报的都是“假警”。

标准解法是距离变换加分水岭:

function [L, n] = countPills(bw, P) % 输入 bw:清理后的二值掩膜;输出 L:连通域标号,n:片数 bw = imclearborder(bw); % 压边的不完整片剔除,交人工 D = bwdist(~bw); % 每个前景点到最近背景的距离 D = -D; % 取负,让“中心”变成谷底 D(~bw) = -Inf; % 背景强制为分水岭的汇水盆地边界 Lw = watershed(D); % 分水岭分割 Lw(~bw) = 0; % 背景置零 L = zeros(size(bw)); cnt = 0; for k = 1:max(Lw(:)) m = (Lw == k); if sum(m(:)) >= P.minArea * 0.35 % 剔除分水岭产生的碎片 cnt = cnt + 1; L(m) = cnt; end end n = cnt; end

逐段解释一下。bwdist算的是欧氏距离,药片是近似圆形的凸体,距离变换的极大值就落在每片的几何中心,两片粘连的地方会形成一个鞍点,分水岭正是沿着鞍点切开。D(~bw) = -Inf这一行不能省,否则背景区域也会被分割成无数块,后面的循环直接爆掉。

P.minArea * 0.35这个系数是经验值。分水岭在粘连处切出来的碎片通常只有完整片的 10% 到 40%,取 0.35 能滤掉绝大多数碎片,但如果两片严重重叠(重叠超过 60%),切出来的两块都接近完整片大小,这时候面积判据就兜不住了,得靠圆形度和圆拟合残差——重叠片的轮廓不是圆,resid会明显偏大。

还有一种更朴素的做法:如果粘连是偶发的,直接按面积除以中位单片面积四舍五入估个数就行,简单粗暴但很有效。

% 兜底方案:面积估数,适合粘连率低于 5% 的产线 stats = regionprops(L, 'Area'); areas = [stats.Area]; area0 = median(areas); % 单片面积基准 estNum = round(sum(areas) / area0); % 总面积的商,四舍五入

这个估法在叠片(三片摞一起)时会低估,但对“两片贴着”这种最常见的情况足够,而且没有分水岭过分割的风险。我一般两个都实现,用params.m里一个开关切换,跑回归集对比谁的误判少就用谁。

4. 边缘与判据:Canny、圆拟合残差和面积周长怎么组合成 NG 结论

分割只回答了“药片在哪”,缺陷判定才回答“这片行不行”。这一步的核心思路是:把每片的轮廓拟合成一个理想圆,然后看实际轮廓偏离这个圆有多远。崩边、缺角、裂片,本质上都是轮廓的局部凹陷。

4.1 边缘检测的选型:Canny 拿来定位,圆拟合拿来量化

方法优点短板在这个项目里的用法
edge(I,'Sobel')快,梯度方向直接可得边缘粗,阈值敏感只用于快速预览
edge(I,'Canny')单像素宽、双阈值抑制虚假边缘sigma 一大就抹平小缺口定位轮廓、做可视化
二值掩膜边界bwboundaries闭合、无需调阈值精度受分割质量直接制约主力,喂给圆拟合
亚像素拟合精度可到 0.1 像素实现复杂,速度慢高精度规格时才上

实际跑下来,bwboundaries这条路最稳,因为分割阶段的形态学已经把轮廓整理干净了,边界点天然闭合,不会出现 Canny 那种断断续续要自己连的情况。Canny 我主要留着做两件事:一是调参时叠在原图上肉眼确认边缘位置对不对,二是当分割阶段出问题时用它交叉验证是不是阈值的事。

如果规格要求高到需要亚像素,常见做法是对每个边缘点沿法向取灰度剖面,做抛物线拟合找极值点,把整数坐标插值成小数坐标。这套实现量不小,一般项目用不到,先用整像素圆拟合把流程跑通再说。

4.2 圆拟合残差:把“崩边”变成一个可比较的数字

function [xc, yc, r, resid, maxDev] = fitCircle(x, y) % Kasa 最小二乘圆拟合:x^2 + y^2 = 2*xc*x + 2*yc*y + (r^2 - xc^2 - yc^2) % 先把坐标平移到质心再解,否则 A 矩阵条件数很差,结果会飘 x = x(:); y = y(:); mx = mean(x); my = mean(y); xs = x - mx; ys = y - my; A = [2*xs, 2*ys, ones(numel(xs),1)]; b = xs.^2 + ys.^2; p = A \ b; % 最小二乘解 xc = p(1) + mx; yc = p(2) + my; r = sqrt(p(3) + p(1)^2 + p(2)^2); d = sqrt((x - xc).^2 + (y - yc).^2); resid = std(d - r); % 半径残差的标准差:整体形状偏离程度 maxDev = max(abs(d - r)); % 最大偏差:局部崩边就体现在这里 end

三个输出各有分工。r是等效半径,可以和标称半径比对,判断是不是整片偏小(模具磨损的典型特征)。resid是残差标准差,反映整体轮廓的“不圆程度”,椭圆片、变形片在这里报警。maxDev是最大偏差,崩边这种局部凹陷主要看它——一片直径 160 像素的圆,崩掉宽 4 像素、深 3 像素的一个角,maxDev大约会到 3 左右,而resid可能只有 0.8,光看resid就漏了。

坐标平移到质心这一步是数值稳定性问题。药片在图像右下角时,x、y都是几百上千的量级,平方之后到 10^6,而半径信息在 10^4 量级,直接解正规方程会丢掉有效数字,拟合出来的圆心偶尔会飞到图像外面去。平移之后所有坐标都在 ±100 以内,条件数立刻正常。这个坑我在一张 2048×2048 的大图上结结实实踩过一次,当时还以为是\运算符有问题。

4.3 判据组合与有无检测:三张表合成一个结论

单片判据用表格理清楚更直观:

特征计算方式典型阈值对应缺陷
面积偏差(A - A0) / A0±12%缺角、大片崩边、叠片
圆形度4πA / P²≥ 0.82裂片、变形、严重崩边
半径残差 std圆拟合resid≤ 1.8 px整体不圆、椭圆化
最大偏差圆拟合maxDev≤ 3.0 px局部崩边、缺角
内部灰度标准差std(gray(mask))≤ 0.06污点、裂片、双片重叠

最后一行灰度特征容易被忽略。背光成像下,两片叠在一起的位置透光率明显偏低,灰度直方图会出现第二个峰;药片上有污点或裂纹时,局部灰度方差也会变大。这一条判据基本不增加计算量,但对叠片特别有效——几何特征在叠片时反而可能“看起来正常”。

判定函数把这些串起来,同时处理有无检测:

function rep = judge(props, P) n = numel(props); if n == 0 rep.pass = false; rep.num = 0; rep.reason = 'empty'; return end areaMed = median([props.area]); % 用中位数而不是均值,抗单片异常 rep.num = n; rep.items = struct('idx',{},'ng',{},'reason',{}); for k = 1:n reasons = {}; if abs(props(k).area - areaMed) / areaMed > P.areaTol reasons{end+1} = 'area'; end if props(k).circ < P.minCirc reasons{end+1} = 'shape'; end if props(k).resid > P.residTol || props(k).maxDev > 3.0 reasons{end+1} = 'chip'; end rep.items(k).idx = k; rep.items(k).ng = ~isempty(reasons); rep.items(k).reason = strjoin(reasons, '|'); end rep.missing = n < P.expectNum; % 缺片 rep.extra = n > P.expectNum; % 叠片或多投 rep.pass = ~(rep.missing || rep.extra || any([rep.items.ng])); end

areaMed用中位数是个刻意的选择。一批 24 片里如果混进一片叠片,面积是正常的两倍,用均值的话基准会被拉高 4%,本来合格的小片就被误判了。中位数在污染比例低于 50% 时完全不受影响。

expectNum这个判据依赖治具的固定工位。如果产线是自由摆放、片数不固定,那就不能靠这个数判断有无,得改成“面积总和除以单片基准面积”的估数法,或者干脆只判单片质量、把计数交给上游的称重工位。这一点在跟工艺确认需求的时候必须问清楚,不然后面全是无用功。

5. 避坑排查:分割翻车、计数飘、换批次就崩的五个现场记录

这一章记的都是真实发生过的现场问题,每条按“现象 → 原因 → 解决”写。新手对着排查能省不少时间。

5.1 成像与分割阶段:整幅图变白、边缘发虚

现象一:跑完segment,bw整幅图全是true,figure出来一片白。原因:背光成像下药片比背景暗,Otsu 算出来的前景其实是背景,忘了取反;另一种可能是顶帽变换时bgRadius取得太小,药片自己被当成背景减掉了,剩下的全是噪声。 解决:先imhist(gray)看直方图是不是双峰、峰在哪一侧,用figure; imshowpair(img, bw, 'montage')并排看一眼。忘了取反这种低级错,加一行断言就能兜住:assert(mean(bw(:)) < 0.5, '前景占比过高,检查是否忘记取反')。

现象二:崩边判不出来,maxDev永远小于 2。原因:blurSigma设成了 2.0,一两个像素宽的缺口被高斯核抹平了,后面再怎么调阈值也找不回来。 解决:把blurSigma降到 1.0 以内,改在中值滤波上压噪(medfilt2(gray, [3 3])),中值滤波对椒盐噪声有效又不糊边缘。代价是运算慢一点,单帧多几毫秒。

5.2 形态学与计数阶段:数量总差一两片

现象三:明明 24 片,n稳定报 25 或 26。原因:药片表面的刻字、凹槽形成暗环,imfill之前这些小洞被算成了背景,bwboundaries沿着洞的内壁又描了一圈,regionprops把内圈也算成了一个连通域。 解决:imfill(bw,'holes')必须在连通域标号之前,而且要确认洞是真的被填满了——imfill只填“完全被前景包围”的区域,如果洞有一处开口通到外面就填不上。可以在imfill后接一次imclose,把开口封住再填。

现象四:n稳定少一片,maxDev却有片报得特别大。原因:两片粘连,分水岭把其中一片切成了两块碎片,碎片面积小于minArea*0.35被丢掉,剩下的一块又被当成完整片。 解决:把碎片阈值降到 0.2,同时给分水岭加一个“最小间距”约束——两片中心距小于0.8 * 等效直径时不做分割,直接按面积估数。分水岭的过分割问题没有通用解,只能靠回归集反复试。

5.3 参数迁移与性能:换批次崩、单帧超时

现象五:样图上调好的参数,换一个批次误判率飙升到 15%。原因:threshScale硬编码成 1.05,那个批次的药片颜色深、整体偏暗,Otsu 阈值本来就偏低,再乘 1.05 前景收缩过度,边缘片被切碎。 解决:threshScale一律给 1.00,让 Otsu 自适应;每批开机时先跑三张良品图,统计前景占比,占比落在 60% 到 75% 之外就报警提示重新标定。这套“开机自检”比事后调阈值靠谱得多。

现象六:离线跑 40 ms 一张,产线上要 120 ms,节拍跟不上。原因:调试用的imshow、imshowpair、plot全留在主流程里;每帧都imread一张未经压缩的大图;循环里没有预分配结构体数组。 解决:把可视化全部收进showResult,上线版永不调用;图像在采集阶段就用无损压缩或 ROI 裁剪,把视场缩到治具范围,能砍掉一半像素;measure循环里的结构体先按n预分配,MATLAB 的结构体数组在循环里动态增长非常慢。这三条做完,同一套算法能压到 30 ms 以内。

6. 二次开发:把 demo 脚本改成能挂到产线节拍上的检测函数

单张图跑通只是起点。真正要交付的时候,代码要面对的是“换批次、换规格、连续跑八小时”这些工程约束。

第一件事是接口收敛。我习惯把所有对外能力收成一个函数:

function rep = inspect(img, P) % 对外唯一入口:输入一帧图像和参数,输出结论结构体 % 上线时会被 C# 或 C++ 侧通过 MATLAB Compiler SDK 调用 [bw, gray] = pill.segment(img, P); [L, n] = pill.countPills(bw, P); props = pill.measure(L, gray, P); rep = pill.judge(props, P); rep.n = n; end

调用方只需要拿到rep.pass和rep.items(k).reason,内部怎么改形态学、怎么换边缘算法,对外完全透明。这条约定立下来之后,同事换了一版基于形态学梯度的边缘定位,接口没动,产线侧一行代码都没改。

第二件事是回归集和混淆矩阵。没有回归集的调参就是赌博。main_batch.m干的就是这个:

% main_batch.m —— 跑 ok/ng 两个文件夹,出混淆矩阵和追溯表 P = params(); sets = {'ok', true; 'ng', false}; yTrue = []; yPred = []; names = {}; for s = 1:size(sets,1) files = dir(fullfile('data', sets{s,1}, '*.png')); for i = 1:numel(files) img = imread(fullfile(files(i).folder, files(i).name)); rep = inspect(img, P); yTrue(end+1) = sets{s,2}; yPred(end+1) = rep.pass; names{end+1} = files(i).name; end end C = confusionmat(yTrue, yPred); disp(array2table(C, 'RowNames', {'NG','OK'}, 'VariableNames', {'predNG','predOK'})); % 把漏检的样本名单独列出来,逐个肉眼复核 miss = names(~yTrue & yPred); % 真值 NG、预测 OK = 漏检 if ~isempty(miss) fprintf('漏检 %d 张:%s\n', numel(miss), strjoin(miss, ', ')); end

confusionmat约定真值和预测都是逻辑或类别标签,这里true表示 OK。漏检(NG 判成 OK)和误检(OK 判成 NG)在产线上的代价完全不对等——漏检会流出不良品,误检只是停机复检。所以调参的时候不能只看总准确率,要单独盯漏检数,宁可误检率高一倍也不能放过一片崩边。

第三件事是收尾清单。下面这张表是我每次交付前必过的:

检查项合格标准常见漏项
可视化调用上线入口里零imshow调试时加的figure忘了删
阈值来源全部来自params.m函数体里残留魔法数字
单帧耗时连续 100 帧均值 < 节拍只测了第一帧
回归集漏检为 0,误检 < 3%只跑了 ok 集
异常输入空图、全黑图不崩没测n == 0分支
参数留档每次交付存一份当时的P只存了代码没存参数

那次最惨的教训是把一段判据逻辑写在了showResult里改颜色——调试时一切正常,上线版删掉可视化之后,NG 片全被判成了 OK,因为那个ng标志是在画图函数里顺手赋值的。

从那以后我每次交付前都强制走一遍“空图 + 全黑图 + 无可视化”三连测试,任何一步报错或者结果不对,就不许发布。这套习惯听着笨,但确实把线上事故压到了零。

希望帮到你。

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

RELATED READING

延伸阅读

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