简介:本资源为基于卷积神经网络的柑橘成熟度识别完整项目包,面向具备Python与PyTorch基础、希望上手图像分类实战的开发者与学习者,可用于农产品分拣、智能农业等场景的算法验证与课程设计。包内共120个文件,以113张jpg与1张jpeg柑橘图像构成数据集,涵盖健康与病害等类别,另含3个py脚本与3个txt说明文件,压缩包约10.8MB。三个脚本分工明确:01数据集文本生成制作.py读取各类别图片路径与标签,02深度学习模型训练.py完成训练并保存本地模型,03pyqt_ui界面.py提供可视化交互入口。数据预处理阶段通过短边补灰边将图片统一为正方形,并施加旋转等增强手段扩充样本,提升模型泛化能力。目前已有237人学习下载,适合作为卷积网络分类流程的入门范例,帮助读者理解从数据组织、增强、训练到界面部署的完整链路。
1. 柑橘成熟度识别为什么值得用卷积网络重做一遍
果园里判断一颗柑橘该不该摘,老师傅靠的是看颜色、摸软硬、掂重量,这套经验在十亩地里管用,到了上千亩的规模化果园就彻底不够用了。人工分选一天看几千颗果子,眼睛疲劳之后标准就开始飘,同一批果子里熟度判断能差出两三个等级。更麻烦的是,柑橘的成熟过程不是「青转黄」这么简单——果皮从青绿到橙黄再到橙红,中间还夹杂着转色不均、日灼斑、虫眼这些干扰,传统靠 HSV 阈值或者单一颜色特征做分类的方案,换一个品种、换一个光照条件就翻车。这就是卷积神经网络(CNN)切入的地方:它不依赖人工设计的颜色规则,而是从大量标注图像里自己学出「什么样子叫熟」的特征表达。这篇笔记围绕「基于卷积神经网络的柑橘成熟度识别」这个方向,把数据集怎么组织、模型怎么选、训练参数怎么调、部署时踩什么坑,按我实际做过的路径拆开讲。适合正在做农产品分级、果蔬品质检测的算法工程师,也适合刚入门想找一个完整视觉项目练手的学生——只要你手上有几百张以上带标注的柑橘图像,这套流程就能跑通。
2. 数据集怎么组织:从采集到划分的完整链路
2.1 成熟度等级定义与标注规范
做识别项目,第一步不是选模型,是定义「成熟度」到底分几级。我见过太多项目在这里埋雷:标注员对「半熟」的理解不一致,训出来的模型在验证集上准确率虚高,一上真实分选线就崩。常见做法是按果皮转色面积比例来切分,而不是凭感觉。比如定义四个等级:青果(转色面积小于 20%)、转色初期(20% 到 50%)、转色后期(50% 到 80%)、完熟(80% 以上)。这个比例怎么量?可以用标注工具在果皮上框出转色区域,也可以让多个标注员独立判断后取多数投票。关键是标注规范要写成文档,每个等级配 5 到 10 张典型图作为锚点,标注员每标 100 张就抽检 10 张,一致性低于 90% 就停下来重新对齐标准。
标注格式建议直接用分类文件夹结构,每个等级一个文件夹,图片放进去就行。如果后续要做检测(框出果子位置再分类),那就用 YOLO 格式的 txt 标注,每行是类别 x_center y_center width height,坐标归一化到 0 到 1。分类任务比检测任务简单,但检测能解决「一张图里多个果子」的场景,看你的实际产线是单果拍摄还是整筐拍摄。
2.2 数据增强与类别不平衡处理
柑橘图像采集有个天然问题:完熟果子容易采,青果和转色初期的果子在果园里不好找,导致类别数量差好几倍。直接拿这种数据训,模型会偏向多数类,把青果也判成完熟。处理办法分两步走。
第一步是离线增强少数类。用旋转、翻转、亮度抖动、随机裁剪把青果和转色初期的样本量补到和完熟差不多。注意亮度抖动范围别太大,±20% 就够了,否则青果被调亮之后看起来像转色果,反而制造噪声。第二步是在训练时用加权采样或者 Focal Loss,让模型对少数类的错误更敏感。
import os import random from PIL import Image, ImageEnhance from torch.utils.data import WeightedRandomSampler import torch # 假设数据目录结构:data/train/青果、data/train/转色初期 ... def augment_minority_class(class_dir, target_count): """对指定类别目录做离线增强,补到 target_count 张""" images = [f for f in os.listdir(class_dir) if f.endswith(('.jpg', '.png'))] current = len(images) if current >= target_count: return need = target_count - current for i in range(need): src = random.choice(images) img = Image.open(os.path.join(class_dir, src)) # 随机旋转 ±30 度 angle = random.uniform(-30, 30) img = img.rotate(angle, expand=True, fillcolor=(255, 255, 255)) # 随机水平翻转 if random.random() > 0.5: img = img.transpose(Image.FLIP_LEFT_RIGHT) # 亮度抖动 ±20% enhancer = ImageEnhance.Brightness(img) img = enhancer.enhance(random.uniform(0.8, 1.2)) img.save(os.path.join(class_dir, f'aug_{i}_{src}')) # 加权采样:让每个 batch 里各类别比例接近均衡 def make_weighted_sampler(labels): """labels 是每个样本的类别索引列表""" class_counts = torch.bincount(torch.tensor(labels)) weights = 1.0 / class_counts[labels] sampler = WeightedRandomSampler(weights, num_samples=len(labels), replacement=True) return sampler上面这段代码里,augment_minority_class负责把少数类补到目标数量,旋转角度控制在 ±30 度是因为柑橘是近似球形,转太多角度和原图差异不大,反而浪费存储。make_weighted_sampler是训练时的采样策略,weights取类别频率的倒数,这样每个 batch 里青果和完熟果出现的概率就拉平了。注意replacement=True表示有放回采样,少数类会被重复抽到,这是故意的。
2.3 训练集、验证集、测试集的划分比例
划分比例没有绝对标准,但有几个原则。如果总样本量在 2000 张以下,按 7:1.5:1.5 分,验证集和测试集各留 15%。如果样本量超过 5000 张,可以按 8:1:1 分。关键是划分时要保证每个类别的比例在三个集合里一致,不能训练集里完熟占 50%,测试集里完熟只占 20%。用sklearn的train_test_split加stratify参数就能做到分层划分。
另外,如果图像是从视频里抽帧来的,相邻帧高度相似,随机划分会导致训练集和验证集里出现几乎一样的图,验证准确率虚高。正确做法是按视频片段划分,同一个片段的所有帧只进一个集合。这个坑我在一个果园监控项目里踩过,当时验证集准确率 98%,上线后实际只有 70% 出头,排查半天才发现是数据泄漏。
3. 模型选型与训练:从 ResNet 到轻量级网络的取舍
3.1 为什么选 ResNet 做基线而不是自己搭
柑橘成熟度识别本质是细粒度分类任务——不同等级之间的差异主要在果皮颜色和纹理的细微变化上,不是「猫和狗」那种大类差异。自己搭一个五六层的卷积网络,特征提取能力往往不够,训到后面 loss 降不下去。ResNet 的残差连接能缓解深层网络的梯度消失问题,18 层或 34 层在几千张图的规模上刚好够用,再深就容易过拟合。
我一般先用 ResNet-18 做基线,输入尺寸 224×224,预训练权重用 ImageNet 的。为什么用预训练?因为柑橘数据集通常只有几千张,从零训收敛慢且容易过拟合,ImageNet 上学到的边缘、纹理特征对果皮识别有迁移价值。冻结前几层,只训后面的 layer3、layer4 和全连接层,学习率设 1e-3,训 30 个 epoch 看验证集准确率能不能到 90% 以上。如果不够,再解冻更多层做微调,学习率降到 1e-4。
3.2 训练脚本与关键参数设置
import torch import torch.nn as nn import torchvision.models as models from torch.utils.data import DataLoader from torchvision import transforms, datasets # 数据预处理:训练集用增强,验证集只做 resize 和归一化 train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), # 随机裁剪到 224 transforms.RandomHorizontalFlip(), # 水平翻转 transforms.ColorJitter(brightness=0.2, contrast=0.2), # 颜色抖动 transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) # ImageNet 统计值 ]) val_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_dataset = datasets.ImageFolder('data/train', transform=train_transform) val_dataset = datasets.ImageFolder('data/val', transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4) # 模型:ResNet-18 + 修改全连接层输出类别数 model = models.resnet18(pretrained=True) num_classes = len(train_dataset.classes) # 假设 4 个成熟度等级 model.fc = nn.Linear(model.fc.in_features, num_classes) # 冻结前几层 for name, param in model.named_parameters(): if 'layer3' not in name and 'layer4' not in name and 'fc' not in name: param.requires_grad = False device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) # 损失函数和优化器 criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1) # 训练循环 for epoch in range(30): model.train() running_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() scheduler.step() # 验证 model.eval() correct, total = 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() print(f'Epoch {epoch+1}, Loss: {running_loss/len(train_loader):.4f}, Val Acc: {correct/total:.4f}')这段脚本里几个参数值得展开说。batch_size=32是 8GB 显存下的稳妥选择,如果显存够可以加到 64,但学习率要相应调到 2e-3。RandomCrop(224)配合Resize(256)是标准做法,先放大再随机裁,让模型看到不同位置的果子。ColorJitter的 brightness 和 contrast 都设 0.2,再大就可能把青果调成转色果,制造标签噪声。优化器用 Adam 而不是 SGD,是因为 Adam 对学习率不敏感,新手不容易调崩,但如果你追求最后几个点的精度,SGD + momentum 0.9 往往能略好一点,代价是要花时间调 lr schedule。
3.3 学习率调度与早停策略
StepLR每 10 个 epoch 把学习率乘 0.1,这是最省事的调度方式。但如果你发现验证集准确率在第 15 个 epoch 就饱和了,后面 15 个 epoch 基本没涨,那就该上早停。早停的逻辑是:连续 5 个 epoch 验证集准确率没有提升超过 0.5%,就停止训练,保存验证集上最好的那组权重。这样既省时间,又避免过拟合。
另一个技巧是余弦退火(CosineAnnealingLR),学习率按余弦曲线从 1e-3 降到 1e-6,训练后期模型能在局部最小值附近更精细地搜索。我在一个柑橘项目里对比过,余弦退火比 StepLR 在验证集上高了 1.2 个百分点,但训练时间多了 20%。值不值得看你的算力预算。
4. 避坑与排查:那些让准确率虚高或崩盘的细节
4.1 验证集准确率 95% 但上线就废
现象:训练时验证集准确率一路涨到 95% 以上,部署到实际分选线上,同一批果子识别准确率不到 70%。
原因:最常见的是数据泄漏。如果训练集和验证集的图来自同一段视频的相邻帧,模型实际上在「背」图,不是学特征。另一个原因是采集环境和产线环境差异大——训练图是在实验室均匀光照下拍的,产线是传送带上动态拍摄,有运动模糊和反光。
解决:按视频片段或采集批次划分数据集,确保同一批次的图只进一个集合。上线前用产线环境拍 200 张图做测试集,如果准确率掉超过 10 个百分点,就要在训练集里加入产线环境的图做域适应。
4.2 模型把「背景」当成熟度线索
现象:模型在测试集上表现很好,但把果子换个背景(比如从白色托盘换到传送带)就判错。
原因:卷积网络会偷懒,如果训练集里完熟果子总是放在红色托盘上,青果总是放在蓝色托盘上,模型可能学的是托盘颜色而不是果子颜色。这是典型的 shortcut learning。
解决:训练时做背景随机化,把果子抠出来贴到不同背景上,或者用 CutMix、MixUp 这类增强手段强迫模型关注果子本身。更直接的办法是采集时就统一背景,但实际项目里往往做不到,所以增强是必须的。
4.3 类别不平衡导致少数类召回率极低
现象:完熟果子的召回率 98%,青果召回率只有 60%,大量青果被误判成转色初期。
原因:青果样本少,模型见得太少,决策边界偏向多数类。
解决:除了前面说的加权采样和离线增强,还可以用类别加权的 CrossEntropyLoss,给少数类更高的 loss 权重。权重设成类别频率的倒数,再归一化到均值为 1。如果少数类实在少(比如只有几十张),考虑用 few-shot learning 或者数据合成,但合成图像的质量要仔细验证,别引入伪影。
4.4 输入尺寸和归一化不一致
现象:训练时用 224×224 输入,部署时为了省算力改成 192×192,准确率掉 5 个百分点。
原因:卷积网络对输入尺度敏感,尤其是最后一层全连接层的权重是按特定特征图尺寸训的。归一化参数不一致也会导致分布偏移。
解决:部署时的预处理必须和训练时完全一致,包括 resize 的插值方法(默认 bilinear 就别改成 nearest)、归一化的 mean 和 std。如果非要改输入尺寸,就在训练时用多尺度训练,让模型见过不同尺寸。
4.5 模型文件太大塞不进边缘设备
现象:ResNet-18 的权重文件 45MB 左右,目标设备只有 20MB 存储空间。
原因:没做模型压缩。
解决:换 MobileNetV3 或 ShuffleNetV2,同样输入尺寸下权重能压到 10MB 以内,准确率掉 2 到 3 个百分点。或者对 ResNet-18 做剪枝和量化,INT8 量化后模型能缩小到原来的四分之一,推理速度还能快一倍。量化时注意校准集的代表性,用 200 张覆盖各成熟度等级的图做校准,别随便拿几张就完事。
5. 从训练到落地:推理脚本与精度验证的实操技巧
训练完模型只是走了一半路,真正落地还要解决推理脚本怎么写、精度怎么验证、模型怎么更新这三个问题。这一章讲我实际项目里用的推理流程和验证方法。
5.1 单张图像推理与批量测试脚本
import torch from PIL import Image from torchvision import transforms # 加载模型结构并恢复权重 model = models.resnet18(pretrained=False) model.fc = nn.Linear(model.fc.in_features, 4) # 4 个成熟度等级 model.load_state_dict(torch.load('best_model.pth', map_location='cpu')) model.eval() # 推理预处理:必须和验证集一致 infer_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) class_names = ['青果', '转色初期', '转色后期', '完熟'] def predict(image_path): img = Image.open(image_path).convert('RGB') tensor = infer_transform(img).unsqueeze(0) # 加 batch 维度 with torch.no_grad(): outputs = model(tensor) probs = torch.softmax(outputs, dim=1) conf, pred = torch.max(probs, 1) return class_names[pred.item()], conf.item() # 批量测试并统计混淆矩阵 from sklearn.metrics import confusion_matrix, classification_report import os y_true, y_pred = [], [] test_dir = 'data/test' for idx, cls in enumerate(class_names): cls_dir = os.path.join(test_dir, cls) for fname in os.listdir(cls_dir): pred_cls, _ = predict(os.path.join(cls_dir, fname)) y_true.append(idx) y_pred.append(class_names.index(pred_cls)) print(confusion_matrix(y_true, y_pred)) print(classification_report(y_true, y_pred, target_names=class_names))推理脚本里最容易出错的是预处理不一致。Resize((224, 224))直接拉伸到正方形,如果训练时用的是Resize(256) + RandomCrop(224),那推理时也应该用Resize(256)然后中心裁剪 224,而不是直接拉伸。拉伸会改变果子的长宽比,球形果子被拉成椭圆,特征分布就偏了。另外model.eval()和torch.no_grad()都要加上,否则 BatchNorm 层会用当前 batch 的统计量,单张推理时结果不稳定。
5.2 用混淆矩阵定位薄弱类别
跑完批量测试,别只看总体准确率,一定要看混淆矩阵。我遇到过总体准确率 92% 但青果和转色初期互相误判严重的情况——这两个类别在颜色上确实接近,模型学到的决策边界模糊。定位到问题后,针对性地补这两个类别的边界样本,或者把二分类改成序回归(把成熟度当成连续值预测,再切分成等级),后者在类别有序的场景下往往更稳。
验证集和测试集的准确率差距如果超过 5 个百分点,说明模型过拟合了,要么加数据,要么加正则化(Dropout、Weight Decay)。如果训练集准确率本身就不高,那是欠拟合,该换更大的模型或者解冻更多层。
5.3 模型迭代与版本管理
模型上线不是终点。产线运行一段时间后,会积累新的数据——可能是新品种的柑橘,也可能是不同季节的光照变化。这些数据要定期回流,人工标注后加入训练集,重新训练模型。我一般每积累 500 张新标注图就做一次增量训练,学习率设小一点(1e-4),只训 10 个 epoch,避免把之前学到的特征覆盖掉。
版本管理上,每次训练的模型文件、对应的训练配置、验证集准确率、混淆矩阵都存一个记录。别用model_final.pth这种名字,用resnet18_20250101_acc0.94.pth这种带日期和指标的命名,后面排查问题时能省很多时间。这个习惯是我踩过坑之后养成的——曾经有一次上线后发现效果不对,想回滚到上一版,结果发现旧模型被覆盖了,只能重训,白白浪费两天。希望这些经验能帮到你,少走点弯路。
本文还有配套的精品资源,点击获取