☰
MATLAB胶囊缺陷检测:从灰度图分割到阈值整定的工程实践
2026/10/20 21:54:27 网站建设 项目流程

简介:这份资源是面向计算机视觉与人工智能初学者、制药工业质检方向学习者的MATLAB胶囊缺陷检测项目实战包,围绕图像预处理、特征提取、CNN模型训练与分类器设计展开,帮助读者理解如何用MATLAB深度学习工具箱完成从图像到缺陷判别的完整流程。压缩包共45个文件,约19.18MB,包含13个tiff与13个png图像样本、12个m脚本、4个mat数据文件,以及readme、txt说明和doc文档,覆盖数据集、算法代码与文字解释三类内容,便于对照运行与调试。目前已有176人学习下载。项目涉及去雾增强、HSV颜色空间处理、模板匹配、梯度引导滤波、相似度计算与结果评估等环节,读者可据此掌握图像质量改善、特征学习、模型训练与性能指标分析的实践思路,并借助MATLAB可视化工具观察中间结果,适合作为课程设计、毕业设计或工业质检入门的参考案例。

1. 胶囊缺陷检测为什么总在边缘翻车:从一张灰度图说起

胶囊缺陷检测这件事,听起来像是机器视觉里最基础的那类任务——目标形状固定、背景干净、缺陷类型也就那么几种。但真在产线上跑过一轮的人都知道,翻车往往不在模型本身,而在成像和预处理。同一批胶囊,正面打光时边缘是亮的,背光时边缘是暗的,侧光又会在圆弧面上拉出一条高光带,这条高光带在二值化之后经常被当成缺口。更麻烦的是胶囊本身是透明或半透明的,内部粉末的分布会直接改变透射光的强度,导致同一颗完好胶囊在不同位置拍出来的灰度值能差出三四十个等级。

这个方向适合两类人:一类是手上已经有工业相机和MATLAB授权、想快速搭一套能跑通的检测流程的工程师;另一类是在做课程设计或小批量验证,需要一套不依赖深度学习框架、能在普通工控机上跑出结果的方案。核心诉求其实就三个:怎么把胶囊从背景里稳定分割出来、怎么把真正的缺陷和光照伪影区分开、怎么把判定阈值调到误检和漏检都能接受的水平。后面几章就按这个顺序拆,先讲成像和预处理,再讲分割和特征提取,最后落到阈值整定和批量验证。

2. 从灰度图到二值掩膜:胶囊区域分割的四个关键步骤

2.1 为什么先做背景估计而不是直接阈值分割

直接对原始灰度图做imbinarize或者 Otsu 阈值分割,在胶囊边缘有反光的情况下几乎必然出问题。反光区域的灰度值可能比胶囊本体还高,Otsu 会把反光当成前景,把胶囊边缘切掉一块。我一般会先做一次背景估计,用大窗口的形态学开运算或者中值滤波得到背景光照分布,再用原图减去背景,把光照不均匀的影响压下去。

% 读取图像并转为灰度 img = imread('capsule_sample.png'); if size(img, 3) == 3 gray = rgb2gray(img); else gray = img; end % 背景估计:用大窗口中值滤波模拟光照分布 % 窗口大小要大于胶囊宽度,一般取胶囊短轴像素数的 2~3 倍 bg = medfilt2(gray, [101 101]); % 背景减除,得到光照校正后的图像 % 加 128 偏移是为了避免负值被截断,后续再归一化 corrected = gray - bg + 128; corrected = uint8(min(max(corrected, 0), 255)); % 显示校正前后对比 figure; subplot(1,2,1); imshow(gray); title('原始灰度图'); subplot(1,2,2); imshow(corrected); title('背景校正后');

这里medfilt2的窗口大小是核心参数。窗口太小,背景估计会跟着胶囊本体走,减除之后胶囊内部反而变暗;窗口太大,计算量上去,而且对局部光照突变的补偿能力下降。我的经验值是窗口边长取胶囊短轴像素数的 2 到 3 倍,比如胶囊短轴在图像里占 40 个像素,窗口就取 101 或 121。校正之后胶囊和背景的对比度会明显拉开,边缘反光带也会被压成一条细线,后续分割就稳得多。

2.2 自适应阈值与形态学后处理的参数配合

背景校正之后,用imbinarize的自适应模式或者adaptthresh做局部阈值分割。全局阈值在这里仍然不够稳,因为胶囊两端和中间的透射光强度本身就有差异。自适应阈值的关键参数是邻域大小和敏感度系数,邻域大小一般取胶囊短轴的 1.5 到 2 倍,敏感度系数从 0.4 开始试,根据分割结果微调。

% 自适应阈值分割 % 邻域大小取 51,敏感度系数 0.45 T = adaptthresh(corrected, 0.45, 'NeighborhoodSize', 51); bw = imbinarize(corrected, T); % 形态学后处理:先闭运算填内部小孔,再开运算去边缘毛刺 % 结构元素用圆盘,半径根据胶囊边缘的粗糙程度选,一般 2~4 se = strel('disk', 3); bw_closed = imclose(bw, se); bw_opened = imopen(bw_closed, se); % 去除小面积连通域,面积阈值取胶囊平均面积的 1/5 bw_clean = bwareaopen(bw_opened, 500); % 填充内部孔洞 bw_filled = imfill(bw_clean, 'holes'); figure; subplot(1,3,1); imshow(bw); title('自适应阈值结果'); subplot(1,3,2); imshow(bw_closed); title('闭运算后'); subplot(1,3,3); imshow(bw_filled); title('最终掩膜');

闭运算和开运算的顺序不能反。先闭运算填掉胶囊内部因为粉末分布不均产生的小暗孔,再开运算去掉边缘因为反光残留产生的毛刺。如果反过来,开运算会先把边缘的细小缺陷和毛刺一起抹掉,闭运算再填孔时就会把缺陷区域也填上,导致漏检。bwareaopen的面积阈值要结合图像分辨率来定,一般取胶囊平均面积的五分之一到三分之一,太小去不掉噪声,太大会把真实的小缺陷一起删掉。

2.3 用区域生长补上断裂的胶囊轮廓

有些胶囊表面有深色印字或者裂纹,自适应阈值分割之后这些位置会断开,胶囊被切成两段。这时候用区域生长把断裂的部分连起来比继续调阈值更稳。区域生长的种子点选在掩膜中面积最大的连通域的质心,生长准则用灰度相似性加距离约束。

% 找到最大连通域的质心作为种子点 stats = regionprops(bw_filled, 'Area', 'Centroid'); [~, idx] = max([stats.Area]); seed = round(stats(idx).Centroid); % 区域生长 % 灰度容差 15,距离约束用 8 邻域 bw_grown = regiongrowing(corrected, seed(2), seed(1), 15); % 与原始掩膜取并集,补上断裂部分 bw_final = bw_filled | bw_grown; bw_final = imfill(bw_final, 'holes'); bw_final = bwareaopen(bw_final, 500);

regiongrowing不是 MATLAB 自带函数,需要自己写一个或者用图像处理工具箱里的grayconnected替代。灰度容差这个参数很敏感,容差太小补不上断裂,容差太大胶囊会从断裂处“漏”到背景里去。我一般从 10 开始试,每次加 5,直到断裂处刚好连上、背景没有明显扩张为止。距离约束用 8 邻域就够了,4 邻域在斜向断裂时连不上。

2.4 掩膜质量的自检指标

分割完之后不能直接往下走,得先看一眼掩膜质量。我习惯算两个指标:一个是掩膜面积和胶囊理论面积的比值,正常应该在 0.9 到 1.1 之间;另一个是掩膜边界的圆度,用regionprops的Perimeter和Area算,圆度明显偏离 1 说明边缘有大量毛刺或者缺口。

% 掩膜质量自检 stats_final = regionprops(bw_final, 'Area', 'Perimeter', 'Centroid'); area_ratio = stats_final.Area / theoretical_area; % theoretical_area 提前标定 circularity = 4 * pi * stats_final.Area / (stats_final.Perimeter^2); fprintf('面积比: %.2f, 圆度: %.2f\n', area_ratio, circularity); % 面积比超出 [0.85, 1.15] 或圆度低于 0.7 的样本标记为可疑 if area_ratio < 0.85 || area_ratio > 1.15 || circularity < 0.7 warning('掩膜质量可疑,建议人工复核'); end

这两个指标不用追求绝对精确,它们的价值在于批量处理时能快速筛出分割失败的样本。面积比突然掉到 0.6 以下,多半是阈值把胶囊切掉了一块;圆度掉到 0.5 以下,多半是边缘反光没压干净。把这些可疑样本单独存出来人工看一眼,比盲目调参效率高得多。

3. 缺陷特征怎么提:从几何量到灰度统计的取舍

3.1 几何特征:面积、周长、圆度和矩形度

胶囊分割出来之后,缺陷检测本质上是在做异常判断。最直接的一类特征是几何量:缺陷区域的面积、周长、圆度、矩形度、长宽比。缺口和裂纹在面积和周长上表现不同,缺口一般面积中等、周长偏大,裂纹面积小但周长面积比极高。圆度对缺口敏感,矩形度对边缘凹陷敏感。

% 提取每个连通域的几何特征 stats = regionprops(bw_final, 'Area', 'Perimeter', 'BoundingBox', ... 'MajorAxisLength', 'MinorAxisLength', 'Eccentricity'); for k = 1:length(stats) area = stats(k).Area; perimeter = stats(k).Perimeter; circularity = 4 * pi * area / (perimeter^2); bbox = stats(k).BoundingBox; rectangularity = area / (bbox(3) * bbox(4)); aspect_ratio = stats(k).MajorAxisLength / stats(k).MinorAxisLength; fprintf('区域 %d: 面积=%d, 圆度=%.3f, 矩形度=%.3f, 长宽比=%.2f\n', ... k, area, circularity, rectangularity, aspect_ratio); end

这些几何量单独用都不够稳。比如圆度对边缘毛刺极其敏感,形态学后处理稍微重一点圆度就上去了,缺陷反而被抹掉。我的做法是把几何特征和灰度特征串起来用,几何量负责粗筛,灰度统计负责确认。

3.2 灰度共生矩阵与局部方差

灰度共生矩阵(GLCM)能反映纹理的粗糙程度和方向性,对裂纹这类细长缺陷比较有效。局部方差则对区域内部的灰度均匀性敏感,胶囊内部有异物或者气泡时局部方差会明显升高。这两个特征的计算量比几何量大,一般只在几何特征触发可疑之后才算,避免全图跑一遍拖慢节拍。

% 对可疑区域计算 GLCM 和局部方差 % 先裁剪出可疑区域 props = regionprops(bw_final, 'BoundingBox'); for k = 1:length(props) bbox = round(props(k).BoundingBox); roi = corrected(bbox(2):bbox(2)+bbox(4)-1, bbox(1):bbox(1)+bbox(3)-1); % GLCM:距离 1,方向 0 度 glcm = graycomatrix(roi, 'Offset', [0 1], 'NumLevels', 16); stats_glcm = graycoprops(glcm, {'Contrast', 'Homogeneity', 'Energy'}); % 局部方差:用 5x5 窗口 local_var = stdfilt(roi, ones(5)).^2; mean_var = mean(local_var(:)); fprintf('区域 %d: 对比度=%.2f, 均匀性=%.3f, 局部方差均值=%.1f\n', ... k, stats_glcm.Contrast, stats_glcm.Homogeneity, mean_var); end

GLCM 的NumLevels不要设太大,16 或 32 就够了,设到 256 计算量翻好几倍但区分度提升有限。Offset选[0 1]是水平方向,裂纹如果是竖直走向的,这个方向上的对比度反而不明显,需要再加一个[1 0]方向取最大值。局部方差的窗口大小取 5 到 7,太小对噪声敏感,太大缺陷的局部性被平均掉。

3.3 特征归一化与组合判据

几何特征和灰度特征的量纲差很远,面积是几百到几千,圆度是 0 到 1,局部方差可能是几十到几百。直接拼在一起做阈值判断,量纲大的特征会主导结果。我一般对每个特征做 z-score 归一化,用训练集统计均值和标准差,然后加权求和得到一个综合异常分。

% 特征归一化与综合评分 % mu 和 sigma 从正常样本统计得到 features = [area, circularity, rectangularity, mean_var]; mu = [1200, 0.85, 0.75, 45]; % 示例值,需根据实际数据标定 sigma = [300, 0.08, 0.06, 15]; % 示例值 z = (features - mu) ./ sigma; weights = [0.3, 0.2, 0.2, 0.3]; % 权重根据验证集调 anomaly_score = sum(abs(z) .* weights); if anomaly_score > 2.5 fprintf('判定为缺陷,异常分 %.2f\n', anomaly_score); else fprintf('判定为正常,异常分 %.2f\n', anomaly_score); end

权重的分配没有理论最优解,只能靠验证集试。我的经验是面积和局部方差的权重给高一点,圆度和矩形度给低一点,因为前两者对真实缺陷的响应更稳定。异常分阈值从 2.0 开始试,每 0.25 一档,看误检和漏检的平衡点在哪里。

4. 阈值整定与批量验证:把误检和漏检压到可接受范围

4.1 用 ROC 曲线找最佳工作点

阈值整定不能靠单张图拍脑袋。我一般会准备一批标注好的样本,正常和缺陷各占一半,数量至少各 50 张。对每个样本算异常分,然后画 ROC 曲线,找约登指数最大的那个点作为初始阈值。

% 批量计算异常分并画 ROC scores = zeros(num_samples, 1); labels = zeros(num_samples, 1); % 1 为缺陷,0 为正常 for i = 1:num_samples % 这里调用前面的分割和特征提取流程 scores(i) = compute_anomaly_score(sample_paths{i}); labels(i) = ground_truth(i); end % 画 ROC 曲线 [X, Y, T, AUC] = perfcurve(labels, scores, 1); figure; plot(X, Y, 'LineWidth', 2); xlabel('假正率'); ylabel('真正率'); title(sprintf('ROC 曲线,AUC=%.3f', AUC)); grid on; % 找约登指数最大点 [~, best_idx] = max(Y - X); best_threshold = T(best_idx); fprintf('最佳阈值: %.3f, 对应真正率 %.3f, 假正率 %.3f\n', ... best_threshold, Y(best_idx), X(best_idx));

AUC 低于 0.85 说明特征组合区分度不够,得回去看是分割不稳还是特征选得不对。约登指数最大点给出的是理论最优,实际产线上还要根据漏检和误检的代价调整。漏检代价高就把阈值往低调,误检代价高就往高调。

4.2 交叉验证与阈值稳定性

单次划分训练集和验证集得到的阈值可能过拟合。我一般做 5 折交叉验证,看阈值在不同折之间的波动。波动超过 15% 说明样本量不够或者特征不稳定,需要补样本或者换特征。

% 5 折交叉验证 cv = cvpartition(labels, 'KFold', 5); thresholds = zeros(5, 1); for fold = 1:5 train_idx = training(cv, fold); val_idx = test(cv, fold); % 在训练集上找最佳阈值 [~, ~, T_train, ~] = perfcurve(labels(train_idx), scores(train_idx), 1); [~, best_idx] = max(...); % 约登指数 thresholds(fold) = T_train(best_idx); end fprintf('各折阈值: %s\n', mat2str(round(thresholds, 3))); fprintf('阈值波动: %.1f%%\n', 100 * std(thresholds) / mean(thresholds));

波动在 10% 以内算比较稳,可以直接用均值作为最终阈值。波动在 10% 到 20% 之间,建议补样本再跑一轮。超过 20% 就别急着上线了,先把特征工程做扎实。

4.3 产线节拍与算法耗时的平衡

MATLAB 在工控机上跑单张图的耗时主要花在背景估计和 GLCM 上。背景估计的中值滤波窗口 101x101,单张 1280x1024 的图大概要 80 到 120 毫秒;GLCM 如果全图跑,再加 50 毫秒。产线节拍如果是 200 毫秒一张,这个耗时就很紧张。

% 耗时分析 t_total = tic; % 背景估计 bg = medfilt2(gray, [101 101]); t_bg = toc(t_total); t_total = tic; % 自适应阈值 T = adaptthresh(corrected, 0.45, 'NeighborhoodSize', 51); bw = imbinarize(corrected, T); t_thresh = toc(t_total); t_total = tic; % GLCM 只在可疑区域算 % ... t_glcm = toc(t_total); fprintf('背景估计: %.1f ms, 阈值分割: %.1f ms, GLCM: %.1f ms\n', ... t_bg*1000, t_thresh*1000, t_glcm*1000);

优化方向有三个:一是把背景估计的窗口从 101 降到 81,耗时能降三成,代价是光照补偿精度略降;二是把 GLCM 从全图改成只在几何特征触发可疑之后才算,正常样本直接跳过;三是把图像分辨率从 1280x1024 降到 1024x768,前提是缺陷在低分辨率下仍然可分辨。这三个手段可以叠加,但每叠加一个都要重新验证一遍阈值,因为特征分布会变。

5. 避坑与排查:胶囊缺陷检测里最容易翻车的五件事

5.1 掩膜把反光带当成缺陷,异常分虚高

现象:完好胶囊的边缘反光带被分割进掩膜,圆度掉到 0.6 以下,异常分超过阈值,误检率飙升。

原因:背景估计的窗口太小,反光带没有被完全压掉;或者自适应阈值的敏感度系数设得太低,反光带被当成前景。

解决:把背景估计窗口加大到胶囊短轴的 3 倍以上,敏感度系数从 0.45 往上调到 0.5 到 0.55。如果还不行,在分割之前加一步高光抑制,用imtophat或者形态学闭运算把窄亮带先抹掉。

5.2 胶囊内部粉末导致局部方差误判

现象:完好胶囊因为内部粉末分布不均,局部方差偏高,被判定为缺陷。

原因:局部方差的窗口太小,粉末颗粒的灰度波动被当成异常;或者归一化用的均值和标准差是从另一批粉末密度不同的样本统计的。

解决:局部方差窗口从 5 加大到 7 或 9,让粉末颗粒的波动被平均掉。归一化参数必须用同一批次的正常样本重新统计,不同批次的粉末密度差异大的话,每批都要单独标定。

5.3 区域生长从断裂处漏到背景

现象:胶囊表面有深色印字,分割后断裂,区域生长把背景也连进来了,掩膜面积暴涨。

原因:灰度容差设得太大,断裂处的灰度梯度和背景接近,生长过程从断裂处溢出。

解决:灰度容差从 10 开始往下调,每次减 2,直到断裂刚好连上、背景不扩张。同时加距离约束,用grayconnected的 8 邻域模式,或者自己写生长函数时限制生长步数不超过胶囊短轴的像素数。

5.4 阈值在不同批次之间漂移

现象:同一套阈值在 A 批次上误检率 2%,换到 B 批次误检率跳到 15%。

原因:两批胶囊的透光率或者表面粗糙度有差异,导致灰度分布整体偏移,固定阈值失效。

解决:每批上线前用 20 到 30 张正常样本重新算一遍均值和标准差,更新归一化参数和阈值。如果批次间差异是系统性的,考虑在预处理阶段加一步直方图规定化,把当前批次的灰度分布对齐到参考批次。

5.5 形态学结构元素选错导致小缺陷被抹掉

现象:裂纹类缺陷在形态学后处理之后消失,漏检率升高。

原因:开运算的结构元素半径太大,裂纹宽度小于结构元素直径时被直接抹掉。

解决:结构元素半径从 2 开始试,每 0.5 一档往上加,同时用一批已知有裂纹的样本验证漏检率。如果裂纹宽度确实很小,考虑把开运算去掉,改用中值滤波去毛刺,中值滤波对细长结构的保留能力比形态学开运算好。

6. 把检测流程封装成可复用的函数:参数外置与日志留痕

走到这一步,流程已经能跑通了,但每次换批次都要改脚本里的参数,容易出错。我一般会把整个流程封装成一个函数,所有可调参数放在一个结构体里,函数只负责执行,参数从外部传入。这样换批次时只改参数文件,不动主逻辑。

function result = detect_capsule_defect(img_path, params) % 胶囊缺陷检测主函数 % 输入:img_path 图像路径,params 参数结构体 % 输出:result 包含判定结果和中间量的结构体 img = imread(img_path); if size(img, 3) == 3 gray = rgb2gray(img); else gray = img; end % 背景估计 bg = medfilt2(gray, [params.bg_window params.bg_window]); corrected = gray - bg + 128; corrected = uint8(min(max(corrected, 0), 255)); % 自适应阈值 T = adaptthresh(corrected, params.sensitivity, ... 'NeighborhoodSize', params.thresh_neighborhood); bw = imbinarize(corrected, T); % 形态学后处理 se = strel('disk', params.se_radius); bw = imclose(bw, se); bw = imopen(bw, se); bw = bwareaopen(bw, params.min_area); bw = imfill(bw, 'holes'); % 特征提取 stats = regionprops(bw, 'Area', 'Perimeter', 'BoundingBox'); if isempty(stats) result.label = -1; % 分割失败 result.score = NaN; return; end [~, idx] = max([stats.Area]); area = stats(idx).Area; perimeter = stats(idx).Perimeter; circularity = 4 * pi * area / (perimeter^2); % 归一化与评分 features = [area, circularity]; z = (features - params.mu) ./ params.sigma; score = sum(abs(z) .* params.weights); result.label = score > params.threshold; result.score = score; result.mask = bw; result.corrected = corrected; % 日志留痕 log_entry = sprintf('%s, score=%.3f, label=%d, area=%d, circ=%.3f', ... img_path, score, result.label, area, circularity); fid = fopen(params.log_file, 'a'); fprintf(fid, '%s\n', log_entry); fclose(fid); end

参数结构体从外部 JSON 或 MAT 文件读进来,换批次时只改参数文件。日志留痕这件事看着不起眼,但批量跑几千张图之后,哪张图判错了、当时的特征值是多少,全靠日志回溯。我吃过一次亏,一批样本误检率异常,但没记日志,只能把几千张图重跑一遍找原因,从那以后日志就成了标配。

参数文件长这样:

params.bg_window = 101; params.sensitivity = 0.48; params.thresh_neighborhood = 51; params.se_radius = 3; params.min_area = 500; params.mu = [1200, 0.85]; params.sigma = [300, 0.08]; params.weights = [0.6, 0.4]; params.threshold = 2.5; params.log_file = 'detection_log.csv';

换批次时,先用新批次的正常样本重新统计mu和sigma,再跑一遍 ROC 找threshold,其他参数一般不用动。如果新批次的成像条件变了,比如换了光源或者镜头,bg_window和sensitivity也要重新试。

最后说一个我自己的习惯:每次调完参数,不管结果好坏,都把参数和对应的误检漏检率记在一张表里。调参这件事很容易陷入“改了 A 好了 B 坏了”的循环,有张表在手,至少知道哪些参数组合已经试过、效果如何。这个习惯帮我省了很多重复劳动,也希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询