在市场端,消费者对水果品相的要求越来越高,果皮上一点碰伤、霉斑或者虫眼,都可能让整箱货降级甚至滞销。而在产地的分级包装环节,大量人工分拣依然是最普遍的方案——肉眼盯着传送带挑果,效率不稳定、标准不统一,而且连续工作几小时后漏检率明显上升。我这次研究的课题,就是用一个纯Matlab环境下的机器学习算法组合,实现缺陷水果的自动识别与分选,把人工从重复劳动里解放出来一部分。整个项目从图像采集、缺陷分割、特征提取到分类器训练,全部基于Matlab R2021b环境完成,代码做到了模块化,回头换水果种类或者改缺陷类型,改少量参数就能复用。这篇文章不打算讲太多虚的理论推导,重点把这套方案我踩过坑以后的真实实现路径写清楚,包括核心算法的选择逻辑、关键参数的调试过程,以及我在实际样本集上跑出来的评估结果。
1. 项目背景与核心问题拆解
1.1 为什么要用机器学习替代人工分选
水果分选这个环节,过去几十年基本靠“老师傅带新徒弟”的方式维持。老师傅靠经验判断果面缺陷等级,但人和人之间的标准差距很大,同一个苹果,在不同分拣员眼里可能判出两个级别。更关键的是,人眼在长时间单调作业后,疲劳带来的注意力衰减是生理性的,无法通过管理手段彻底消除。
用机器视觉配合机器学习算法来做分选,解决的不仅仅是“替代人力”这一个问题,它改变的是整个品控链条的标准化程度。算法模型一旦训练完成,同一批次、同一光照条件下,它的判断标准是绝对一致的,不会因为班次交替、情绪波动或者疲劳程度发生变化。这条确定性,是人力分选难以做到的。
从投入产出比来看,一套基于普通工业相机的视觉检测系统,加上工控机和软件部署,成本大约是一台进口色选机的零头,却能把绝大多数常见的表面缺陷(碰压伤、腐烂、虫眼、日灼斑)识别出来。对于日产几十吨的中小型果品企业来说,这个性价比是很合理的切入点。
1.2 缺陷果检测的技术难点到底在哪
水果缺陷检测听起来像是“看图片、分分类”,但真正上手做会发现难啃的地方非常多。
第一个难点是缺陷的多样性。苹果的轮纹病斑、橘子的青霉腐烂、桃子的褐变压痕,形态上差异很大,就算限定在同一类水果,缺陷的纹理、颜色、形状也会因为品种和成熟度不同而呈现出高离散度。一套固定规则做不到通吃,必须靠模型学习特征分布。
第二个难点是光照不均带来的干扰。流水线上常见的光源照射方式,会在果实表面形成镜面反射高光区和曲面阴影。高光区域的颜色饱和度过低,阴影区域色温偏冷,如果不做校正直接送进模型,很容易把高光误判成缺陷,把阴影误判成损伤。
第三个难点是样本不平衡。实际产线上,好果的比例一般占到80%以上,缺陷果中每个类别的占比就更不均衡了。直接拿原始样本训练,模型会天然偏向多数类,导致缺陷果的召回率低得没法用。
第四个必须重视的难点,是果梗和花萼区域的干扰。这两个部位在图像上呈现深色、纹理复杂,与真实腐烂区域在外观上高度相似,算法很容易误报。后面我在特征设计部分会专门讲怎么用小波纹理特征把这个干扰压下去。
2. 整体方案设计与技术选型
2.1 为什么选Matlab而不是OpenCV加Python
图像处理和机器学习的技术栈,现在最流行的是OpenCV + Python + scikit-learn这套组合,但我在这个项目里最终选择了Matlab,有几个非常现实的理由。
Matlab的Image Processing Toolbox和Statistics and Machine Learning Toolbox几乎是开箱即用的。从图像读入、灰度变换、形态学操作到分类器训练,全部函数化封装,参数含义直白,不需要像OpenCV那样在不同版本的API之间来回适配。做研究性质的原型验证,Matlab的迭代效率是最高的。
另一个优势是变量的可视化调试。Matlab的变量工作区可以随时查看矩阵大小、数据类型,imshow可以叠加多个图窗联动观察,处理中间结果出了问题能立刻定位。在写图像分割算法时,中间每一步的效果能直观看到,比纯命令行环境排查问题舒服得多。
而且Matlab的Classifier Learner App可以把KNN、SVM、决策树、判别分析这些模型一键训练和交叉验证,几分钟内就能横向对比一批模型的效果。这个阶段对摸清数据特性非常有帮助。
当然,Matlab不是没有缺点。它的部署环节不如Python灵活,Coder生成的代码在嵌入式端移植需要额外调优,如果目标是做低成本的ARM嵌入式方案,最后还得迁移。但对这个课题来说,验证“机器学习能否稳定分选缺陷果”这个核心问题是完全足够的。
2.2 特征工程思路与分类器选型
在进入特征工程之前,我先明确了一条原则:这个场景不用深度学习的卷积神经网络,全部采用手工特征加传统机器学习分类器的路线。原因有三:一是样本量有限,深度学习小样本训练容易过拟合;二是分选任务部署环境的算力有限,传统机器学习模型推理速度极快;三是缺陷检测需要比较强的可解释性,人工特征能够让工程师针对误检样本准确定位问题。
特征体系分成三组:
- 颜色特征:在HSV空间统计H分量直方图、S分量均值和方差,以及颜色矩。缺陷区域往往在色相上与正常果皮显著不同,这是判别能力最强的特征组。
- 纹理特征:基于灰度共生矩阵GLCM计算对比度、相关性、能量和同质性,再叠加一层小波分解后的高频子带能量。纹理特征负责区分“腐烂这类质地变化”和“仅仅是颜色差异”的情况。
- 形状特征:缺陷区域的面积占比、外接矩形长宽比、等效椭圆离心率。这些特征用来过滤掉过小的噪点类伪区域。
分类器的选择上,我对比了K近邻、支持向量机(SVM)和随机森林三个方向。KNN实现简单但特征维度高时性能衰减快,而且推理时要遍历全部样本;SVM在小样本下泛化能力不错,但对参数敏感;随机森林天然抗过拟合,能输出特征重要性,对混合型特征很友好。最终的评测结果显示,在同样的特征集上,随机森林和RBF-SVM的准确率都在95%上下,随机森林的训练时间只有SVM的约七分之一,而且对类别不平衡的鲁棒性更好,所以我选择了随机森林做最终方案。
2.3 分选流水线的整体架构
整套系统按照产线逻辑可以拆成四个环节:
- 图像采集:工业相机配合封闭暗箱,LED环形光源提供均匀照明,光电传感器触发拍照,保证果实姿态在固定位置被捕获。
- 算法处理:基于Matlab编写的图像分析与分类脚本,完成从原始图像到缺陷分类标签的完整推理。
- 分选执行:根据算法输出的类别标签,控制电磁阀驱动的气嘴阵列,将不同等级的果实吹入对应出口。
- 数据记录:每一颗果实的图像、特征和分类结果都记录到本地数据库,用于后续的数据追溯和模型迭代。
严格来说,我在这个课题里重点完成的是第2个环节,也就是核心算法部分,但整套架构的设计从一开始就是按工业可用的标准来的,不是做一个小Demo就结束。
3. 图像预处理与缺陷区域分割实操
3.1 颜色空间的选择:RGB与HSV之争
图像进来以后第一件事,就是考虑在哪个颜色空间里做分析。RGB空间是相机原生输出的格式,各通道之间的相关性比较高,缺陷跟正常果皮的差异往往散布在三个通道里,不容易用固定阈值切开。
我在实验里同时尝试了RGB和HSV,结果非常明确:HSV空间的H(色相)通道对光照变化更鲁棒。红苹果的果皮在RGB里的R通道数值很高,但G和B通道也很高,缺陷区域的判别边界模糊;转换到HSV之后,正常果皮的H值集中在很窄的区间,无论是碰伤导致的褐变还是霉烂导致的发暗,都会让H值发生漂移,这个漂移量远大于正常果皮自身的变化范围。
另外,S(饱和度)通道对区分“高光”和“真实缺陷”特别有用。高光区域的饱和度极低,缺陷区域的饱和度通常很高,这两个在HSV空间里天然分离。我用S通道的高斯分布统计配合固定阈值,能比较准确地排除掉大多数镜面反射区域。
3.2 背景分割与果实区域提取
图像里的背景如果全部送进特征计算,会带来巨大的无用干扰。我的方案是先做背景分割,只保留果实区域。
Matlab里的实现思路:采集时使用黑色吸光背景布,果实区域和背景的灰度差非常明显。读入原图后转灰度,用Otsu自适应阈值做二值化。这里注意一点,Otsu的阈值是自动计算的,不用人工指定,鲁棒性远好于固定灰度值。
% 读取图像并分离背景 img = imread('apple_sample.jpg'); hsv = rgb2hsv(img); gray = rgb2gray(img); % Otsu自动阈值分割 bw = gray > graythresh(gray); % 形态学清理:先开运算去噪,再闭运算填充小洞 bw = imopen(bw, strel('disk', 5)); bw = imclose(bw, strel('disk', 15)); % 提取最大连通区域作为果实主体 cc = bwconncomp(bw); areas = cellfun(@numel, cc.PixelIdxList); [~, idx] = max(areas); mask = false(size(bw)); mask(cc.PixelIdxList{idx}) = true;这里有个生产环境必须注意的细节:如果果实之间紧密堆叠,连通域会把两个果体连成一个整体,分割就会失效。解决方案是在采集端做机械约束,用单列滚轮传送带或者双通道定位槽保证每次只拍到一个果实,在源头避免粘连问题。
3.3 缺陷区域增强与缺陷候选提取
分割出果实区域之后,下一步是把缺陷候选区域从果面中分离出来。这一步决定后面特征计算的质量。
我的方法可以先概括为“差分处理加多尺度观察”。正常果皮的表面纹理是由细小的角质层结构形成的,在频域上属于中低频信息,而缺陷区域无论是腐烂还是压伤,都会造成局部纹理突变和颜色畸变,在频域上体现为高频能量显著抬升。
具体实现:用MATLAB的imtophat顶帽变换配合一个较大尺寸的结构元素(半径15像素),可以提取出比周围区域更暗的缺陷结构;再用imbothat底帽变换提取更亮的异常结构。两幅差分图叠加,通过阈值化得到缺陷候选二值图。
% 顶帽变换提取暗缺陷(腐烂、压伤色斑) se = strel('disk', 15); tophat = imtophat(hsv(:,:,1), se); dark_defect = tophat > 0.08; % 底帽变换提取亮异常(少量白腐、水渍) bottomhat = imbothat(hsv(:,:,1), se); bright_defect = bottomhat > 0.06; candidate = dark_defect | bright_defect; candidate = imopen(candidate, strel('disk', 3));缺陷候选区域提取完成后,我会计算每个连通域的面积占比。面积占比过小的直接剔除,那基本是噪声或者果面上的微小斑点,不会影响分级判定。剩下的区域进入特征提取环节。
4. 特征提取核心环节
4.1 颜色特征:直方图与统计量
颜色特征的设计逻辑是把“缺陷和正常果皮的颜色差异”量化成数字。我在HSV空间里分别统计H、S、V三个通道的均值、标准差以及三阶颜色矩,同时对H通道计算32bin的直方图分布。这样既保留整体色感的偏移趋势,也保留了局部色相分布的具体形态。
我把不同缺陷类别的H通道均值做了一个统计对比:正常果皮的H均值落在0.96-0.99区间,碰伤区域H均值在0.45-0.65,霉腐区域H均值在0.10-0.25,日灼区域偏橙红色在0.03-0.08。这个差异足够大,所以颜色特征在多数缺陷类型上都有很强的区分度。
有个细节需要注意:颜色特征的数值区间差异非常大,H分量范围是0-1,而面积占比是0-100%的量级,S均值也是0-1,直接喂给分类器会把距离度量搞偏,必须做归一化。
function feat = extractColorFeatures(mask, hsv) % 提取mask区域内HSV统计特征 for ch = 1:3 chn = hsv(:,:,ch); vals = chn(mask); feat(ch) = mean(vals); feat(ch+3) = std(vals); feat(ch+6) = sum((vals - mean(vals)).^3) / numel(vals); end % H通道32bin直方图,归一化 h_hist = histcounts(hsv(:,:,1)(mask), 0:1/32:1); feat(10:41) = h_hist / sum(h_hist); end经验值补充一点:三阶颜色矩虽然在数学上看起来不明觉厉,但它捕捉的是颜色分布的偏态,对辨别“局部小面积深色缺陷”和“整体轻微着色不均”很有帮助,是我在一轮测试后专门补进来的特征。
4.2 纹理特征:GLCM灰度共生矩阵
颜色特征能抓住“颜色的不同”,但碰到“颜色接近、纹理不同”的场景就有心无力了。比如苹果表面的轻微碰伤,刚开始可能只是局部细胞破裂,颜色没有立刻变褐,但从纹理上看,正常的细密蜡质纹路被破坏了,表面粗糙度增大。这时候纹理特征就发挥决定性作用。
GLCM的核心思想非常直观:统计一对相距某偏移量的像素在灰度值组合上出现的频次,再从这个联合分布中提取统计量。我用的四项是:
- 对比度:局部灰度差异的总体幅度,缺陷区域的对比度显著偏高。
- 相关性:行列方向灰度变化的线性关系,正常果皮的相关性更高。
- 能量:灰度分布均匀性的度量,均匀果面的能量值高。
- 同质性:局部纹理平滑度的反向指标,缺陷区域同质性下降。
Matlab里的graycomatrix函数直接支持这些操作,我设置了偏移量为[1, 0]和[0, 1]两个方向,取了平均值。
glcm = graycomatrix(gray_crop, 'Offset', [1 0; 0 1], 'NumLevels', 64, 'Symmetric', true); stats = graycoprops(glcm, {'Contrast', 'Correlation', 'Energy', 'Homogeneity'});同时我还做了三层Haar小波分解,提取高频子带HH的系数能量。这个方法是为了进一步强化对外观纹理突变的捕捉,对腐烂区域特别有效。实践下来,GLCM的对比度和HH高频能量两个特征对判别腐烂类缺陷的贡献度排在最前面。
4.3 几何特征与特征归一化
几何特征主要描述缺陷区域的形状。我计算的包括缺陷面积占比、外接矩形的长宽比、圆形度、质心到果心的偏移距离。
其中质心偏移距离是我特意保留的特征。很多自然缺陷(如果实向阳面的日灼斑)倾向于出现在特定部位,而损伤性缺陷(碰压伤)的位置随机性强,这个特征对特定品类有额外判别力。
所有特征提取完毕后,一定要做归一化处理,这是新手最容易踩的坑。我使用的策略是z-score标准化,即每个特征减去均值后除以标准差,使所有特征在模型里地位平等。这个操作在Matlab里可以直接用zscore函数完成。
5. 机器学习模型训练与验证
5.1 样本集的构造与标签策略
做样本集是件看似枯燥但决定成败的事。因为应用场景是分选产线,我需要让训练样本尽量贴近真实工况,而不是实验室里的“完美照片”。
我采集的样本包括五个类别:正常果、碰压伤果、腐烂果、虫眼果、日灼果。每类采集300-500张,总共大约1800张图像。这里我特别强调一个做法:采集时变换果实角度和摆放姿态,模拟传送带上果实现实中的随机朝向,避免模型学到的只是一个固定姿态下的特征分布。
采集完成后按约7:2:1划分为训练集、验证集和测试集。划分时用了分层随机抽样,确保每个类别在各个集合中的比例与原样本集一致,避免抽样偏差。
标签策略上我采用了多分类one-vs-rest方式,最终输出为五个类别的置信度向量。同时我保留了一个“低置信度转人工复检”的策略,置信度低于0.6的判定结果不直接输出,而是标记为待人工确认。这个策略在产线实测中有效减少了误判带来的客诉风险。
5.2 分类器训练:随机森林的调优过程
我用的是Matlab的TreeBagger实现随机森林。初始设定200棵树,每棵树随机采样约37%的样本(默认bootstrap比例),每个分裂节点随机选取sqrt(特征维度)个候选特征。第一次基线训练后,测试集准确率约91%,看起来可用,但细看会发现问题集中在少数类上。
之后我做了三轮针对性调整:
- 增加树的数量到500棵,准确率提升到94%左右,但继续加到1000棵就没有明显收益了,说明模型容量已经饱和。
- 调整最小叶节点数。默认的1导致单棵树深度过大,容易过拟合,我调到10之后,虽然训练集准确率略有下降,但验证集的稳定性好了很多,说明泛化能力提升了。
- 处理样本不平衡。用
Prior参数调整各类别的先验概率,把少数类的权重从默认的比例值提升到均衡状态,这个操作最直接地提升了缺陷类的召回率,特别是虫眼类的召回率从0.52拉升到了0.84。
% 训练随机森林分类器 model = TreeBagger(500, X_train, y_train, ... 'Method', 'classification', ... 'MinLeafSize', 10, ... 'Prior', [0.25 0.25 0.25 0.15 0.10]./sum([0.25 0.25 0.25 0.15 0.10]), ... 'OOBPrediction', 'on');调优完成后的报告显示,OOB(袋外)误差率稳定在5.1%左右,作为参考,同样的特征集上SVM交叉验证的误差率在4.8%上下。虽然SVM准确率略高一点,但随机森林在训练速度上的优势明显,且对异常样本有更好的容忍度,综合产线部署考虑,我最终保留了随机森林。
5.3 模型评估:混淆矩阵与ROC曲线
模型评估不能只看总准确率,我习惯用混淆矩阵和各类别召回率来审视模型短板。在测试集上得到的混淆矩阵概要如下:
| 实际类别 | 正常果 | 碰压伤 | 腐烂 | 虫眼 | 日灼 | 召回率 |
|---|---|---|---|---|---|---|
| 正常果 | 172 | 4 | 0 | 1 | 2 | 95.6% |
| 碰压伤 | 3 | 52 | 2 | 0 | 1 | 89.7% |
| 腐烂 | 0 | 1 | 61 | 1 | 0 | 96.8% |
| 虫眼 | 1 | 0 | 2 | 45 | 2 | 90.0% |
| 日灼 | 1 | 1 | 0 | 1 | 48 | 94.1% |
整体准确率94.3%,宏平均召回率93.2%。最低的类别是碰压伤的89.7%,原因是碰压伤早期颜色变化不明显,与正常果皮在特征空间的距离较近,这个结果也在合理范围内。
ROC曲线方面,我用perfcurve函数计算了各类别的一对多ROC曲线。腐烂类的AUC达到了0.989,这主要得益于GLCM纹理特征与小波高频能量特征的强判别力;碰压伤类的AUC最低,为0.914,同样验证了上面说的特征接近问题。
这里有一个处理多分类置信度阈值的心得:默认的固定阈值对所有类别一视同仁,但产线上不同缺陷类别的成本不同——腐烂果如果漏检混入市场,对整个批次的信誉伤害远大于碰伤果。所以我针对每个类别单独设定阈值,腐烂类阈值提高,宁可多误判一些正常果进入复检通道,也不放过烂果,这就是阈值与商业成本的匹配问题。
6. 常见问题与排查技巧实录
这一节整理我在整个项目过程中真实遇到的高频问题,附带排查思路,给后面想复现的朋友省点时间。
问题一:光照变化导致误检率飙升
更换了一批光源后,整体误检率突然从6%跳到15%。排查发现新光源的色温偏高,果面高光区域面积变大,导致S通道特征分布整体偏移,原本用均值加标准差的检测逻辑失效。处理办法是在预处理流程中加入标定板校色环节:每次开机采集一张标准色卡,计算当前光源下的色彩变换矩阵,对输入图像做统一校正,再进特征提取流程。
问题二:样本不平衡导致少数类召回率过低
这个问题的表现是虫眼类缺陷的召回率只有0.5左右,大量虫眼果被误判为正常果。先用Prior调先验概率改善了一部分,后来又补充采集了1000张虫眼样本做数据增强,包括随机旋转、左右翻转、轻微对比度调整。组合下来虫眼类的召回率提到了0.87。
问题三:过拟合只发生在少数类别上
训练集整体准确率98%,但碰压伤的验证集准确率只有80%多一点。原因是碰压伤的形态变化范围太大,样本量又不足。除了补充样本外,我还将GLCM窗口按像素偏移改为3像素、5像素的滑动均值,相当于对特征做了平滑,有效降低了单棵树的过拟合程度。
问题四:Matlab推理速度跟不上产线节拍
初版算法的单张图像处理时间是400毫秒,而产线节拍要求250毫秒以内才能跟上传送带速度。性能分析发现耗时大头在GLCM计算和顶帽变换上。优化方式是把GLCM的灰度级从64降到32,顶帽变换的半径从15降到10,并将分辨率从1920x1080降为1280x720,最终单张耗时降到180毫秒,准确率只下降了0.4个百分点,属于可接受代价。
| 现象 | 排查方向 | 解决措施 |
|---|---|---|
| 总准确率尚可但缺陷漏检多 | 各类别召回率不均匀 | 调整Prior或采集补充样本 |
| 光照变化后误检率升高 | 检查高光面积与色温漂移 | 引入标定板做在线校色 |
| 训练集好验证集差 | 单棵树过深 | 增大MinLeafSize |
| 推理耗时过长 | GLCM层级过高、分辨率过大 | 降低灰阶数与图像精度 |
再分享一个小的生产经验:批量处理图像时,用parfor并行替换for循环,能直接利用多核心加速特征提取,我实测对1800张样本集可以将处理时间压缩到原来的38%。这是Matlab自带的能力,不用额外配置并行环境,非常值得一用。
7. 项目总结与后续扩展思路
这个项目做下来,我个人的体会是:缺陷水果分选的核心壁垒不在分类器选什么,而在前面数据采集和特征工程的质量。所有模型给到的性能提升,最终都受限于你喂进去的特征能不能把缺陷的本质差异表达出来。如果一开始就把RGB直方图扔进去然后指望SVM出奇迹,结果一定不会太好看。
目前这套Matlab实现已经能够稳定完成离线检测任务,我下一步计划做两件事:
一是把特征提取耗时降下来之后,考虑把模型导出为C代码部署到嵌入式设备上,实现产线在线实时分选。Matlab Coder可以把训练好的随机森林模型转换成C源码,这样脱离了Matlab运行时环境也能跑。
二是给系统增加持续学习能力。产线运行过程中会不断积累新的图片样本,尤其是新的缺陷形态,每隔一段时间用增量数据重新训练模型,让分选标准随着品控要求的提升持续演进。这部分我打算用半监督的方式,先让模型自动标注高置信度样本,只把低置信度的样本送人工确认,再用确认结果做微调。
如果让我给准备做类似项目的朋友一条核心建议,那就是:先把样本集的质量搞上去,再谈算法优化。样本覆盖度决定了模型性能的上限,算法调参只是尽量逼近这个上限。我的经验里,大多数分选系统的翻车都发生在对样本多样性的低估上——觉得500张图够了,跑出来效果也不错,结果一上真实产线就露馅,因为果子的姿态、成熟度、产地的变化,样本多样性翻倍都不一定够。宁可前期多花时间把数据采集扎实,后面所有环节都会顺畅很多。