简介:一套面向红枣表面缺陷检测的Matlab程序包,适合图像处理初学者和农产品质检方向的开发者参考。压缩包仅274KB,共5个文件,包含可直接运行的.m脚本、两种红枣示例图像,以及两份Word说明文档,分别讲解红枣缺陷检测流程和Matlab在钢板表面缺陷检测中的延伸应用,便于对照理解二值化、灰度化等预处理技术在缺陷识别中的实际用法。资源已有351人学习下载,体量虽小但结构完整,可复现从图像采集、预处理、缺陷区域定位到特征计算、缺陷分类与等级评估的完整流程;程序附有示例图便于运行调试,文档还梳理了边缘检测、形态学操作等算法思路,能为自动化生产线上的红枣品质分拣提供算法支撑。
1. 从一筐红枣到一条分选线:这个缺陷检测项目解决的是什么
振动料斗把红枣一颗颗送进单行料道,质检员的眼睛在传送带上扫一遍,下午两点的漏检率往往比上午高出一截。这个项目名里的红枣缺陷检测,做的正是用机器视觉里的图像算法替代人眼:拍一张图,把裂纹、霉变、虫眼从合格果里分开。“zip”只是交付形态,里面要么是已标好的图像集,要么是带训练和推理脚本的基线方案,常见于农产品视觉分级、高校毕设和产线原型验证。它要解决的是从样本采集、标注、建模到工位部署的完整链路。适合想把视觉分选落到小产线的工程师,也适合拿它当起点的学生,前置门槛只有 Python 和基础 PyTorch。后面按选型、数据、训练、踩坑、部署的顺序把它讲透。
2. 先定建模路线再碰代码:红枣缺陷算法选型与数据底线
拿到压缩包别急着解压就跑,先想清楚一个问题:缺陷是“分出来”还是“找出来”。分出来是给一颗枣一个标签——合格、霉变、裂纹;找出来是在图像里标出缺陷所在的区域。标题里写“缺陷检测”,但多数产线是逐果处理的,一颗枣只对应一个结果,分类路线比检测路线便宜得多,也容易落地。
2.1 裂纹、霉变、虫眼、皱缩:把缺陷转成机器能学的标签
红枣表面缺陷大致归成五类。它们的视觉信号差异很大,直接决定数据怎么标、模型怎么选。
| 缺陷 | 主要视觉信号 | 识别难度 |
|---|---|---|
| 霉变 | 颜色发黑发暗、有灰绿菌斑 | 低 |
| 裂纹 | 细长暗纹、与果皮低对比 | 高 |
| 虫眼 | 小孔洞、目标占比小 | 高 |
| 皱缩 | 表面沟壑密集、纹理杂乱 | 中 |
| 机械损伤 | 表皮破损、形状不规则 | 中 |
霉变的颜色信号强,深度学习很容易学,传统阈值分割也能解决一部分。裂纹本质是低对比的线状特征,对光源角度和图像分辨率都敏感。虫眼的问题是目标太小,如果图像里一颗枣只占几百个像素,虫眼可能只有十几个像素,对分类网络来说接近噪音。
对应有三种建模方式。单图分类:每张图一个标签,输出合格或缺陷类型,标注成本最低,产线逐果处理时最常用。目标检测:输出缺陷框的位置,适合一个画面里有多颗枣或者一颗果上多处缺陷的情况,对应 YOLO、RT-DETR 这类网络,代价是每张图都要画框标注。像素级分割:把缺陷轮廓标到像素级,一般用于计算损伤面积占比,农产品分级里很少用,标注成本太高。
我一般从分类网络起步,把标题里的“检测”先理解成“判断是否缺陷”来落地。只有客户明确要求标注缺陷位置、机械臂要根据坐标剔除时,才上检测模型。分类网络跑通之后再升级检测,数据也能复用——分类用的单果图直接当检测网络的图像输入,只是多画一次框。
2.2 传统视觉与深度学习的边界:OpenCV 和 Halcon 为什么不够用
在决定直接上 PyTorch 之前,先看一眼传统图像处理能不能顶住。用 OpenCV 做缺陷检测的老套路是:转 HSV,对 H 通道做阈值分割找霉斑;用 Canny 找边缘,再用形态学闭运算把断线连起来,得到完整裂纹。这套方案在样品少、光照固定的实验台上跑得很快,一台不带 GPU 的工控机就够用。Halcon 里的形态学算子比 OpenCV 更稳,一些封装好的缺陷检测工具开箱即用,但授权费用不低。
它的瓶颈在两点。一是光照敏感,阈值是拿某一批图调出来的,换个光源、换个相机,同一套阈值立刻失效,又得重新调参。二是缺陷形态差异大,同一类裂纹在不同果上的粗细、长度、走向差别很大,固定阈值根本覆盖不住分布。深度学习把这种差异交给网络自己学,换来的代价是要有数据、有 GPU。
选型判断标准很简单:只有几十张样品、明天就要演示,用传统方案顶着;要长期在产线跑、要应对多品种多光照,直接走轻量分类网络加迁移学习的路线。本项目的标题既然打包了数据或脚本,通常是已经有人在深度学习方向上趟过一遍,直接沿用比较划算。
2.3 数据底线与增效手段:迁移学习、增强策略、线性探针
常见错误是拿着两三百张图就开训。给一个经验值:每类缺陷最少 300 张,总样本不低于 2000,这是一个雷打不动的门槛。这里的“每类”包含合格。合格果通常最多,经常上千张,这也是后面样本失衡的根源。
省数据的核心是迁移学习。用 ImageNet 预训练权重初始化的 ResNet18 或 MobileNetV3,在几千张图的小数据集上,收敛速度和泛化能力都远超随机初始化。预训练学到的是边缘、纹理、颜色块这些通用视觉特征,对农产品图像完全迁移得动。
数据增强按产线真实变化设计,而不是把几十个算子全堆上去。旋转 15 度以内,模拟料道里果子的姿态晃动;水平翻转扩大方位覆盖;亮度、对比度扰动,模拟光源衰减和不同时段的光照;随机裁切模拟枣与相机距离的波动。如果缺陷是小目标,比如虫眼,别用 RandomResizedCrop 那种可能把缺陷裁掉的随机裁剪,改用等比例 Resize 加小幅缩放。
还可以用“线性探针”快速判断数据够不够:把预训练模型的特征取出来,只训练一个线性分类头。如果验证准确率能到 70% 到 80%,说明预训练特征里已经有足够区分度,数据量撑得起后续微调;如果线性头的准确率上不去,说明缺陷特征不在 ImageNet 覆盖范围里,先去补数据,别急着调训练参数。
3. 把 zip 包里的散图变成训练集:解压、清洗与标注组织
数据决定模型上限。这个阶段没有花哨的操作,但要细。解压、校验、目录整理、类别统计,每一步都值得按部就班地做一遍,后面训练时能少出很多怪问题。
3.1 先解压再校验,损坏文件和伪加密原地现形
拿到压缩包,先解压并验证完整性。Linux 下的命令:
unzip -q 07红枣缺陷检测.zip -d jujube unzip -t 07红枣缺陷检测.zip第二行是关键。它会对包内每个文件做 CRC 校验,逐个文件输出 OK 说明没坏。网盘转存、下载中断、上传不完整都可能留下损坏文件,等训练到一半报 FileNotFoundError 再回来查压缩包,浪费的时间比现在多做两次完整解压还多。Windows 下用 7-Zip 打开压缩包,工具栏里有“测试”按钮,作用一样。
如果解压时提示输入密码,而你确认这是公开资料,多半是 zip 伪加密——文件头里的加密标志位被人为改过,文件本身并没有加密。用支持修复的压缩工具打开,很多能自动跳过这个标志直接读取。这是识别伪造标志位,不是破解密码;真正加密的资料没有密码就不要尝试。
解压出来的目录通常是每类缺陷一个文件夹:
jujube/ ├── good/ 合格红枣 ├── mold/ 霉变 ├── crack/ 裂纹 ├── worm/ 虫眼 └── wrinkle/ 皱缩这种组织方式叫 ImageFolder 结构,PyTorch 的datasets.ImageFolder会直接按子目录名生成标签,省去手写标签文件的麻烦。如果遇到文件后缀不统一,有的 jpg 有的 png,先用一个循环统一转成 JPG,能避免训练加载时读到空文件。
3.2 标注格式怎么选:单标签分类还是带框目标检测
分类任务的标签就是文件夹名,ImageFolder 读进来后自动按字典序映射成序号,标注成本几乎为零,整理素材时顺手把图挪进对应文件夹就行。
如果原始素材里一张图有多颗红枣,要么先裁剪成单果再入分类目录,要么转到目标检测路线。检测路线用 LabelImg 或 CVAT 画框,导出 YOLO 格式的 txt 或 COCO JSON,每行是类别 ID 加归一化的中心坐标和宽高。这类 zip 资源最常见的就是两种形态之一:按目录分类的图集,或者带labels目录和classes.txt的检测数据集。打开压缩包先看有没有这两个文件,有就是检测路线,没有就按分类目录处理。
我建议在没有明确要求坐标信息时,优先按分类落地。画框标注一个人一小时只能处理两三百张,而分类整理图集一小时能过上千张。等到分类模型验证了业务价值,再升级检测定位,那时补画的框也更有针对性。
3.3 数据划分与类别平衡:脚本一次到位
划分数据集时有个原则:按类内比例划分,而不是全局混洗。保证每个缺陷类在 train、val、test 三份里的占比一致,否则某类缺陷全部进了训练集,验证时就恰好缺这一类,评估结果是假的。
import os import random import shutil random.seed(42) src = "jujube" dst_root = "data" ratios = {"train": 0.7, "val": 0.15, "test": 0.15} for cls in os.listdir(src): cls_path = os.path.join(src, cls) if not os.path.isdir(cls_path): continue files = os.listdir(cls_path) random.shuffle(files) n = len(files) n_train = int(n * ratios["train"]) n_val = int(n * ratios["val"]) parts = { "train": files[:n_train], "val": files[n_train:n_train + n_val], "test": files[n_train + n_val:], } for split, split_files in parts.items(): out_dir = os.path.join(dst_root, split, cls) os.makedirs(out_dir, exist_ok=True) for f in split_files: shutil.copy(os.path.join(cls_path, f), os.path.join(out_dir, f))脚本逻辑是按类别逐类洗牌,再按 7:1.5:1.5 切分并复制到data/train、data/val、data/test对应类别目录下。用 copy 不用 move,是为了划分失手时原始数据还在,有后悔药可吃。seed 固定成 42,之后每次跑脚本得到完全相同的划分,实验可比性有保障。
划分之后马上统计类别数量,看看失衡程度:
find data/train -mindepth 1 -maxdepth 1 -type d | while read d; do echo "$(basename $d) $(find $d -type f | wc -l)" done如果 good 有两千张而 crack 只有两百张,记下来。第 4 章训练时要用加权采样补偿这个差距,评估指标也要从准确率换成宏平均 F1,否则模型会靠狂猜多数类刷分。
注意:解压后的目录如果不是 ImageFolder 结构,而是每张图里有多颗枣,先裁剪或切割成单果图再进分类流程。一张图一个标签的建模,前提就是图里确实只有一颗果。
4. 用 PyTorch 微调一个红枣缺陷分类器:训练代码与四个关键参数
训练部分的核心不是模型结构,而是数据管线、迁移学习策略和优化器参数。下面这套配置在 8GB 显存、几千张红枣图上可以稳定跑通,参数也给了调整边界。
4.1 数据管线:Resize、增强与归一化不能照抄
import torch from torchvision import datasets, transforms from torch.utils.data import DataLoader, WeightedRandomSampler MEAN = [0.485, 0.456, 0.406] STD = [0.229, 0.224, 0.225] train_tf = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomRotation(15), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.15, saturation=0.1), transforms.RandomResizedCrop(224, scale=(0.85, 1.0)), transforms.ToTensor(), transforms.Normalize(MEAN, STD), ]) val_tf = transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(MEAN, STD), ])这段代码的第一个坑是 Normalize 的均值方差不能删。用 ImageNet 预训练权重时,输入归一化必须和预训练阶段一致,否则分布漂移会让模型输出直接失真。第二个坑是训练和验证的增强不对称:训练用 RandomResizedCrop 模拟远近变化,验证用 CenterCrop 保证每张图的评估内容一致,出来的准确率才能横向比较。
ColorJitter 的参数不要给太大。亮度 0.2、对比度 0.15、饱和度 0.1 是我常用的起点,超过这个幅度红枣的颜色会偏离真实,模型反而把颜色当作噪音记进特征。
4.2 换掉 ResNet18 分类头,冻结与解冻按数据量定
import torchvision.models as models model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) num_features = model.fc.in_features num_classes = 5 # good / mold / crack / worm / wrinkle model.fc = torch.nn.Linear(num_features, num_classes) for name, param in model.named_parameters(): if name.startswith("fc"): param.requires_grad = True else: param.requires_grad = False迁移学习的默认做法是冻结主干、只训分类头。预训练特征里已经有边缘、纹理、颜色块这些通用能力,对红枣表面缺陷够用,只训练最后一层全连接就能较快收敛,也不容易过拟合。
但总样本超过五千张时,我会把 layer4 一起解冻,让高层特征针对枣的表面纹理再微调。改法很简单,判断条件从name.startswith("fc")扩成name.startswith("fc") or name.startswith("layer4")。解冻层数要跟着数据量走:数据多、大胆解冻;数据少、严格冻结。
4.3 训练循环:SGD 参数、学习率调度与按验证集保存
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = model.to(device) optimizer = torch.optim.SGD(model.parameters(), lr=0.001, momentum=0.9, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1) criterion = torch.nn.CrossEntropyLoss() best_acc = 0.0 for epoch in range(30): model.train() train_loss = 0.0 for imgs, labels in train_loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() loss = criterion(model(imgs), labels) loss.backward() optimizer.step() train_loss += loss.item() * imgs.size(0) scheduler.step() model.eval() correct, total = 0, 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels = imgs.to(device), labels.to(device) preds = model(imgs).argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) val_acc = correct / total if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), "jujube_defect_best.pth") print(f"epoch {epoch+1:02d} | train loss {train_loss / len(train_loader.dataset):.4f} | val acc {val_acc:.4f}")每个 epoch 先训练再验证,验证时torch.no_grad()关闭自动求导省显存。argmax(dim=1)在概率输出上取最大项对应的索引,与类别序号对应。val_acc 历史最高时才保存权重,避免最后一轮已经过拟合时把坏模型存下来。
四个关键参数按实际经验说明。
SGD momentum 0.9:视觉分类任务里比 Adam 收敛更稳,迁移学习后段尤其明显,Adam 前期快但后期容易在小梯度上震荡。
weight_decay 1e-4:作用在全连接层的 L2 正则,样本少时抗过拟合效果直观。数据量过万时降到 5e-5。
学习率 lr:冻结主干训分类头给 0.001;解冻 layer4 之后降到 0.0001,否则预训练特征被一步冲坏。
batch_size:8GB 显存下 32 是稳妥值,显存不够降到 16,学习率同步按比例减半。batch 减半、lr 不降,收敛曲线会明显变吵。
5. 红枣缺陷检测常见坑与排查:从样本失衡到 zip 损坏
这个阶段的问题大多不在模型,而在数据和环境。四条踩坑记录是我做视觉缺陷项目时反复遇到的,每一条都按现象、原因、解决的顺序写。
5.1 缺陷样本太少:准确率高,坏果漏检全在少数类
现象:训练完看日志,准确率刷到 95%,一测实际产线,霉变果大量漏掉,翻看预测结果发现模型几乎只输出 good。
原因:合格果几千张,霉变只有几十张,模型学到的是“全部预测为 good 就能拿 95% 正确率”。交叉熵损失在这个分布下根本没有逼模型去学少数类的特征。
解决:第一步把评估指标换成宏平均 F1,少数类贡献不了高分时指标才诚实。第二步训练时用 WeightedRandomSampler,按类别样本数取反比做采样权重,让每个 epoch 里霉变样本被抽到的次数提上来。第三步给少数类做更强的增强。三步按顺序做完,仍要记得最优先的动作是去补拍缺陷样本,采样和增强只是弥补手段。
5.2 换个产线就翻车:模型学的是光照和背景
现象:实验台上用同一台相机同一光源测,准确率 93%;挪到客户的厂房,背景变成不锈钢托板,光源色温不一样,检测率直接掉到 70% 以下。
原因:训练数据只覆盖了实验室单一光照条件,模型把背景和光照分布当成类别特征的一部分背了下来。这是传统阈值方案的老问题,深度学习只是把“调阈值”变成了“背特征”。
解决:数据采集尽量贴近真实产线,固定相机和光源,在采集阶段就按产线的实际照明拍。增强阶段把亮度扰动加进去,模拟不同时段的自然光变化。更稳妥的方式是在方案里预留现场二次微调环节:先用旧数据跑通流程,进场在新光源下补拍两三百张,做一轮快速微调再验收。
5.3 验证 loss 开始回升:过拟合的三个信号与早停
现象:训练 loss 一路下降,验证 loss 从某个 epoch 开始不降反升,验证准确率也出现回落。
原因:模型把训练集里的噪声细节背了下来,包括果蒂位置、某个固定拍摄角度、甚至个别标注错误。
解决:加早停,验证 loss 连续十个 epoch 不下降就停。给网络在分类头前插入 Dropout,概率 0.3。把增强强度提上去,尤其是亮度扰动。还有一个容易被忽略的动作是检查标注质量,挑三十张预测错得离谱的图出来看,如果有相当比例是标注本身错了,先去改错标,比调任何训练参数都有效。
5.4 解压报 CRC 错误、提示密码、训练到一半找不到文件
现象:unzip -t报错;解压时要求输入密码但资料明明是公开的;训练中断,提示找不到某张样本图。
原因:下载不完整导致文件损坏,zip 伪加密标志位被改,路径里带中文或空格,也可能是某个样本文件本身是 0 字节。
解决:解压前先做完整性校验,把损坏文件挑出来重新下载。伪加密用支持修复的压缩工具打开,通常能自动跳过标志位读取。路径方面,整个工程和数据目录不要出现中文和空格,Linux 下空格路径要转义,踩过一次就长记性了。
训练中断后不要盲目续跑,先扫一遍数据目录,把 0 字节或者无法解码的文件全列出来:
find . -type f \( -name "*.jpg" -o -name "*.png" \) -size 0 -print坏文件移走,再重新划分和训练。这类问题看着玄学,本质只是数据文件没检查干净。
6. 走到产线这一步:推理脚本、置信度阈值与召回率取舍
训练完成只算走了一半。模型要变成产线上能稳定工作的工具,还需要一个标准的推理接口和一套阈值标定流程。
6.1 把训练好的模型变成产线可用的单图推理接口
import torch import torchvision.models as models from PIL import Image from torchvision import transforms device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = models.resnet18(weights=None) model.fc = torch.nn.Linear(model.fc.in_features, 5) model.load_state_dict(torch.load("jujube_defect_best.pth", map_location=device)) model.to(device).eval() CLASSES = ["good", "mold", "crack", "worm", "wrinkle"] tf = transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) img = Image.open("camera_shot.jpg").convert("RGB") logits = model(tf(img).unsqueeze(0).to(device)) prob = torch.softmax(logits, dim=1)[0] idx = prob.argmax().item() print(f"{CLASSES[idx]} {prob[idx].item():.3f}")这段代码把训练时的模型结构和权重重新组合起来。load_state_dict里的map_location=device是必须的,训练用 GPU、部署机只有 CPU 时,少了它直接报错。输出概率要打出来看,分类结果后面的数字反映了模型有多笃定,这是后续阈值标定的原始数据。
提示:CLASSES 列表的顺序必须和训练时 ImageFolder 的子目录字典序一致。差一个位置,预测结果全部错位。
6.2 置信度阈值标定:漏检与误杀的不对等代价
五类结果里真正需要生产系统响应的是缺陷类。判断逻辑可以简化为:缺陷概率超过阈值 T 就判为坏果。T 越低,召回越高但误杀越多;T 越高,误杀减少但漏检风险上升。
农产品分级里,漏掉一个霉变果进到下游客户手里,代价比错杀一个合格果高得多。我一般会从 0.5 往下调,先测 0.3,拿一部分误杀换更低的漏检率。
阈值不要拍脑袋定。拿出 val 集里所有缺陷样本,用上面的推理脚本跑出缺陷类的概率,在 0.2 到 0.9 之间按 0.05 步长扫一遍,看每个阈值下的召回率和误杀率,选一个生产上能接受的平衡点。这步半小时能完成,却直接影响产线验收指标。
分选工位上,把模型输出和对应的图像一起存进本地 CSV,每周回看一次哪些是误杀、哪些是漏检,再决定要不要补样本做一轮微调。这个把输出图像存档、定期复盘的习惯,是我最早做缺陷检测时最不以为然的环节,直到一次误杀率波动全靠它定位到光源老化才变成固定动作。模型交付不是终点,数据回流才是项目能长期维持检测率的关键,希望帮到你。
本文还有配套的精品资源,点击获取