简介:面向深度学习入门者和图像分类初学者的一套基于PyTorch的柑橘成熟度识别项目,包含完整数据集与三个可运行脚本,能快速跑通数据准备、模型训练与界面预测全流程。压缩包共120个文件,约10.8MB,其中113张柑橘图片构成数据集(含Healthy、Greening等类别),3个txt文本为标签列表与环境依赖说明,3个py脚本分别负责数据集文本生成、模型训练和PyQt5可视化界面。代码在训练前对图像进行预处理:在较短边填充灰边使图片变为正方形(原本为正方形则不变),并加入随机旋转扩增样本,提升模型泛化能力;训练结束后会在本地保存模型权重,便于后续加载复用。目前已有235人学习下载,适合作为课程设计、毕业设计或个人练习的基线项目,拿到后可立即运行并直观观察训练与识别效果。
1. 柑橘成熟度识别:一个CNN项目为什么值得你打开这个zip
很多拿到这个zip的人,第一反应是找代码文件,结果被数据集结构折磨到半夜。标题里真正值钱的不是网络结构,而是“含数据集”这几个字——柑橘成熟度的图像样本,采集成本远高于模型代码本身。用卷积神经网络做成熟度识别,本质是一张图像的多分类:青果、转色、成熟。它不需要画框,只需要图级标签,所以入门门槛低;但想在真实采集的、光照不均匀的数据集上稳定跑到95%以上,要处理的细节很多。这个项目适合两类人:练手CNN全流程的学生,以及农业视觉项目里想验证“自动分级代替人工分拣”的工程师。下面我从数据拆解讲起,到部署收尾,给出可以直接照抄的参数和踩坑记录。
2. 拆开zip先看数据:成熟度标签体系、目录结构与划分策略
拿到带数据集的压缩包,不要急着解压就跑模型,先做三件事:看目录层级、看标签分布、看图片质量。这三步在训练前完成,能省掉后面几十次“调参无效”的复现时间。我见过太多项目死在第一步——不是代码写错了,而是图片路径里有空格、标签大小写混用、某个类别的图片只有十几张。
2.1 解压与目录观察:把数据集当成数据库审一遍
先在命令行里解压,而不是用图形界面,便于观察完整路径。常见做法是建立独立工作目录,避免原始压缩包被误改:
mkdir -p citrus_ripeness && cd citrus_ripeness unzip ../基于卷积神经网络的柑橘成熟度识别-含数据集.zip -d raw注意-d raw决定了解压后的根目录名,后续所有脚本都基于这个raw路径写。如果压缩包带中文名,解压后的路径在 Linux 下容易乱码,在 Windows 下也可能触发 PyTorch 的编码问题。我一般会在解压后立即重命名成英文目录:
mv raw raw_dataset # 假设原来有中文,改成英文 find raw_dataset -maxdepth 3 -type d用find看目录结构,比ls -R更可控。-maxdepth 3防止输出爆炸,同时能看到类似raw_dataset/train/mature这种三级结构。接着统计图片数量和格式:
find raw_dataset -type f \( -name "*.jpg" -o -name "*.jpeg" -o -name "*.png" \) | wc -l这一步给你一个总样本数。如果数量低于500,那后续所有深度学习训练都要谨慎;如果高于5000,倒是足够,但还要看每个类别的分布。统计每个类别数量:
for d in raw_dataset/*/*/; do echo "$(find "$d" -type f | wc -l) $d"; done这段会打印每个子目录的文件数。重点关注“转色”这类过渡态样本数是否远少于“青果”和“成熟”——过渡态往往最难采,也最容易成为短板。
2.2 标签粒度与目录命名:中文标签是第一个坑
数据集里标签的粒度直接决定任务难度。常见的有三分类(未熟/半熟/成熟),也有五分类(青果、淡青、转色、全熟、过熟)。分类越多,类间差异越细微,网络学起来越吃力。我建议第一次跑通时只取三分类,把过渡态合并成“转色”一类,等准确率稳住再细分。
如果原始包已经是train/class1,train/class2这种目录结构,直接用 PyTorch 的ImageFolder加载即可;但如果给的是一个 CSV 标注文件,需要先转成目录结构。我一般这样处理:
import pandas as pd import shutil from pathlib import Path df = pd.read_csv('labels.csv') # 列:image_path, label label_map = {'未熟': 'immature', '半熟': 'transition', '成熟': 'mature'} for row in df.itertuples(): src = Path(row.image_path) label = label_map[row.label] dst = Path('data_by_label') / label / src.name dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst)注意label_map必须显式存在,不要直接拿原始中文当目录名。因为不少预训练模型的默认数据加载器会假定目录名是 ASCII,中文标签轻则乱码,重则报UnicodeDecodeError。映射完后顺手把映射表存成 json,万一后期要改标签,不用重新跑转换。
2.3 数据划分与增强:别让同场景图片同时出现在训练集和测试集里
成熟度识别数据集通常来自果园实地拍摄,同一棵树、同一个角度连续拍几十张。如果随机划分,训练集和验证集里会出现高度相似的背景,模型记住背景就能“作弊”,测试精度虚高。正确划分原则是按“采集批次”或“场景”分组。但多数数据集不提供分组信息,这时候我一般先用图像哈希做去重,再按哈希分组:
import hashlib from PIL import Image import glob from collections import defaultdict def img_hash(path, size=32): img = Image.open(path).convert('L').resize((size, size)) return hashlib.md5(img.tobytes()).hexdigest() files = glob.glob('data_by_label/*/*.jpg') buckets = defaultdict(list) for f in files: buckets[img_hash(f)].append(f) # 同一哈希的图片视为同一场景,分到同一集合这个脚本把所有感知哈希相同的图片归到同一个桶里,之后按桶划分,而不是按图片划分。size=32把图像缩到32×32再算MD5,能滤掉分辨率差异造成的误判。如果你介意哈希碰撞,可以把size调到64,代价是计算变慢。
划分比例我习惯用 7:1.5:1.5,并用分层抽样保证每个桶内的类别比例一致:
from sklearn.model_selection import train_test_split bucket_ids = list(buckets.keys()) labels = [label_from_bucket(b) for b in bucket_ids] # 取每个桶的标签 train_b, val_b = train_test_split(bucket_ids, test_size=0.3, stratify=labels, random_state=42) val_b, test_b = train_test_split(val_b, test_size=0.5, stratify=[...], random_state=42)这里stratify=labels让每个类别的桶数量在三个集合中同比例分布。random_state=42虽然是玄学,但它让复现结果的人拿到一模一样的划分,否则每次跑都不一样,后面调参没法对比。
增强策略不要一上来就重度:随机水平翻转、小角度旋转、亮度抖动就够了。过度增强会让模型学不到质地特征,尤其柑橘的光泽和颜色是成熟度的重要线索,色相抖动幅度要控制在 ±10% 以内。
3. 用PyTorch搭一个能跑通的成熟度分类模型:代码与参数说明
这一章解决“怎么把数据变成模型”。我从 DataLoader 写起,到训练循环结束,全程用 PyTorch。选 PyTorch 不是因为它最快,而是因为ImageFolder和预训练模型接口最省心,适合这个场景下快速验证。
3.1 DataLoader与数据增强:让训练吃上干净的图片
写 DataLoader 前,先定义好图片变换。这里要区分训练集和验证集的变换:训练集要带一点随机增强,验证集只做缩放和归一化,否则验证分数会抖动得很厉害。
from torchvision import datasets, transforms from torch.utils.data import DataLoader train_transforms = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=10), transforms.ColorJitter(brightness=0.1, contrast=0.1, saturation=0.1), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transforms = 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_by_label/train', train_transforms) val_dataset = datasets.ImageFolder('data_by_label/val', val_transforms) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4, pin_memory=True)几个参数说明:Resize((224,224))是预训练模型默认输入尺寸,用其他尺寸也可以但会降低复用性。RandomHorizontalFlip适合柑橘这种无明显朝向的物体,旋转角度不要超10度,否则会切掉边缘。ColorJitter的 saturation 影响很大,柑橘的颜色饱和度一旦被剧烈改变,模型会把“鲜艳”当作“成熟”,所以 saturation 参数我控制在0.1以内。
num_workers在 Windows 上建议设为0,否则可能出现多进程加载错误。Linux 上可以按 CPU 核数的一半设置。pin_memory=True对 GPU 训练有微小提速,但对 CPU 训练没用,可以忽略。
3.2 从零训练一个小型CNN,还是直接用预训练模型?
这个问题在样本量不明确时要慎重。如果数据集只有几百张图,从零训练一个几十层的CNN基本会过拟合,验证集准确率卡在70%左右。先用一个四层卷积的小网络跑通流程,比一上来就用ResNet更稳妥。
import torch.nn as nn class SmallCNN(nn.Module): def __init__(self, num_classes=3): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 16, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(64, num_classes) ) def forward(self, x): return self.classifier(self.features(x))这个网络的卷积核从16到64,每层后接池化,最后的AdaptiveAvgPool2d(1)把特征图压成1×1,后面接全连接输出。它的参数量很小,几百张图也能训,但准确率上限不高。如果第一轮就能在验证集上到85%以上,说明数据质量不错,可以换预训练模型;如果只有70%多,先别急着换模型,回头检查数据划分和标签是否有问题。
3.3 训练循环与超参:学习率、batch size、epochs和早停
训练循环里最容易翻车的不是网络结构,而是学习率和损失函数。多分类任务用CrossEntropyLoss,优化器我常用 AdamW,比 Adam 对权重的解耦更稳。
import torch.optim as optim model = SmallCNN(num_classes=3) criterion = nn.CrossEntropyLoss() optimizer = optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-2) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) best_acc = 0.0 for epoch in range(30): model.train() train_loss = 0.0 for images, labels in train_loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() train_loss += loss.item() scheduler.step() model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: outputs = model(images) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) acc = correct / total if acc > best_acc: best_acc = acc torch.save(model.state_dict(), 'best_smallcnn.pth') print(f'epoch {epoch+1}: loss={train_loss:.4f}, val_acc={acc:.4f}')lr=3e-4是 AdamW 在小数据集上的常见起手值,太大容易震荡,太小收敛慢。weight_decay=1e-2是 L2 正则,用来打压过拟合。CosineAnnealingLR让学习率从3e-4平滑下降到接近0,比 StepLR 更适合图像分类。
早停我没有写进循环,一般靠验证集准确率连续5个epoch不涨就手动停。best_acc保存的是验证集最优模型,而不是最后一个 epoch 的模型——训练后期容易过拟合,保存最优是“后悔药”。如果训练时发现验证集准确率波动很大,请确认是否忘了model.eval(),以及验证集的增强是否被误加到val_transforms里。
4. 调优与评估:混淆矩阵、类别不均衡与迁移学习
这一章回答“模型训出来了,怎么判断它真的能用”。我习惯先看混淆矩阵,再看类别召回率,最后决定是否上迁移学习。顺序不能反,否则你会在一个“总体90%准确率但转色类全错”的模型上浪费时间。
4.1 混淆矩阵看错在哪:过渡态为什么总是被认成两边
from sklearn.metrics import confusion_matrix import numpy as np # 假设 val_loader 和 model 已经存在 all_preds, all_labels = [], [] model.eval() with torch.no_grad(): for images, labels in val_loader: outputs = model(images) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) cm = confusion_matrix(all_labels, all_preds) print(cm)输出矩阵每一行是真实类,每一列是预测类。对角线越高越好。柑橘成熟度常见问题是“转色”类被分到相邻类:因为转色果本身介于青果和成熟果之间,颜色特征连续,判断边界很主观。如果看到transition的召回率只有60%,不要急着调网络,先想想标签本身是否可靠。标注员自己可能都分不清某张图算半熟还是快熟,这种噪声会直接限制模型上限。
4.2 类别不均衡:加权损失是效率最高的解法
如果某个类别样本很少,比如半熟果只有几十张,模型会直接忽略它。最简单的处理是在损失函数里给每个类别加权重,样本越少权重越大。
class_counts = np.bincount(all_labels, minlength=3) weights = 1.0 / np.sqrt(class_counts + 1e-6) weights = weights / weights.sum() * 3 # 归一化到类别数 criterion = nn.CrossEntropyLoss(weight=torch.tensor(weights, dtype=torch.float32))1/√count比1/count温和,避免少样本类别的梯度过于激进。+1e-6是防除零。权重归一化到类别数是为了让学习率不用随权重重调,这是一个我常用的技巧,实测比直接传原权重更稳定。
过采样也是一种选择,但要注意随机增强要足够强,否则只是复制粘贴,会加重过拟合。我更推荐先用加权损失,不行再上WeightedRandomSampler。
4.3 迁移学习实战:用ResNet18把准确率拉高一个台阶
小网络验证流程无误后,换预训练模型是最快提升手段。ResNet18 在 ImageNet 上预训练过的特征对自然图像的纹理、颜色响应很好,适合柑橘这种质地丰富的对象。加载时把最后一层全连接换成自己的分类头:
from torchvision import models model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) num_features = model.fc.in_features model.fc = nn.Linear(num_features, 3)这里weights=models.ResNet18_Weights.DEFAULT在 torchvision 1.13 之后是新写法,老代码的pretrained=True会报 warning。model.fc原本输出1000类,我们换成3类,其余层的预训练权重保留。
迁移学习训练时,有两种策略:一是冻结所有卷积层只训练fc,适合数据量很少;二是整个模型微调,适合数据量中等。我一般先冻结训练10个epoch,再解冻所有层用小学习率微调10个epoch。解冻就是把requires_grad置 True:
for param in model.parameters(): param.requires_grad = False for param in model.fc.parameters(): param.requires_grad = True # 先跑10轮,然后: for param in model.parameters(): param.requires_grad = True optimizer = optim.AdamW(model.parameters(), lr=1e-5)冻结阶段学习率用3e-4,解冻后降到1e-5,否则预训练权重会被破坏得很厉害。这一步通常能让准确率从小网络的85%左右跳到95%。
5. 避坑与排查:数据集和训练中的五个常见问题
这里集中记录我在这类项目上反复踩过的坑,每一条都按“现象→原因→解决”展开,你遇到哪个直接查哪条。
5.1 解压遇“伪加密”或中文路径,ImageFolder报FileNotFoundError
现象:解压时提示输入密码,但实际上这个 zip 没有加密;强行解压后部分文件路径乱码,训练时反复报找不到文件。 原因:某些打包工具会把 zip 的加密标志位设成 1 但不实际加密,这就是“伪加密”,解压软件会误判。再加上压缩包内部路径含中文,Windows 解压成 GBK,PyTorch 按 UTF-8 读就崩。 解决:伪加密不用输密码,用 7-Zip 打开并忽略密码提示即可;如果已经解压乱码,把所有目录名重命名为英文路径。这一步比改代码快得多。
5.2 图片 EXIF 带旋转信息,训练时验证集准确率飘忽不定
现象:某些图片在预览软件里是正的,但读入 ndarray 后是旋转90度的;同一种果实的图片角度不一致,模型学不到稳定特征。 原因:手机拍的 JPEG 会把旋转方向写入 EXIF 的 Orientation 字段,PIL.Image.open默认不自动应用这个旋转,但 OpenCV 读取时又会应用,导致数据读取不一致。 解决:图像预处理阶段统一用 PIL 并手动ImageOps.exif_transpose(img)后再转数组。动手改代码比改所有图片快,而且不会丢像素。
5.3 标签文件编码不一致,CSV 读取出现乱码
现象:pd.read_csv('labels.csv')后标签列出现\ufffd之类的字符,类别映射全部失效。 原因:同一份 CSV,有的行是 UTF-8,有的行是 GBK,多半是手动补录过几次。 解决:先尝试读一个文件,判断乱码比例,然后再做批量编码转换。我一般直接统一转成 UTF-8:
iconv -f GBK -t UTF-8 labels.csv > labels_utf8.csv转完再用file labels_utf8.csv确认编码。
5.4 显存不足,但 batch size 不能太小
现象:显卡只有8GB,batch size 设64直接 OOM,设8又训练速度极慢且准确率不稳定。 原因:输入尺寸224×224,加上中间激活占用显存,小 batch 的梯度噪声大会让训练不稳定。 解决:保持 batch size=32,如果还 OOM,把输入 Resize 到192×192,并在网络后接AdaptiveAvgPool前调整尺寸。也可以开梯度累积:
accumulation_steps = 4 # 相当于 batch_size=32*4 for i, (images, labels) in enumerate(train_loader): loss = loss / accumulation_steps loss.backward() if (i+1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()这里把每次反向传播的 loss 除以累积步数,保证总梯度尺度不变。
5.5 训练损失下降但验证准确率不涨:学习率与初始化的问题
现象:train loss 从1.0降到0.3,val acc 一直卡在70%。 原因:模型过拟合到训练集的背景噪声,或者学习率太高让模型在验证集边界震荡。 解决:先降低学习率重试一次,如果还不行,把验证集临时换成训练集的一部分看准确率。如果训练集准确率也上不去,说明网络容量不够或数据混乱,此时要检查标签是否真的对。我还遇到过预训练模型最后一层初始化权重标准差过大导致微调失效,新fc层的权重建议用nn.Linear默认初始化,不要手动乱设。
6. 把模型变成能用的东西:导出ONNX并在边缘端推理
训练好的模型如果只在服务器上验证,生产价值有限。柑橘成熟度识别大概率要跑在分选线附近的边缘设备上,或者手机端。我习惯把 PyTorch 模型导出为 ONNX,再转成 TensorRT 或 NCNN。导出本身门槛不高,但有几个细节。
6.1 导出ONNX:固定输入尺寸和 batch
model.eval() dummy = torch.randn(1, 3, 224, 224) import torch.onnx torch.onnx.export(model, dummy, 'citrus_ripeness.onnx', input_names=['input'], output_names=['output'], dynamic_axes=None, opset_version=12)dynamic_axes=None表示输入固定 batch=1,这对部署最友好。如果希望支持动态 batch,可以设置dynamic_axes={'input': {0: 'batch'}},但会牺牲部分推理引擎的优化空间。
6.2 在边缘端落地:验证导出模型与调整精度
导出后先用 ONNX Runtime 验证输出一致:
import onnxruntime as ort import numpy as np onnx_session = ort.InferenceSession('citrus_ripeness.onnx') test_input = np.random.randn(1, 3, 224, 224).astype(np.float32) out = onnx_session.run(None, {'input': test_input})跑通后,你会发现 ONNX Runtime 在 CPU 上的速度已经比 PyTorch 快,因为省掉了 Python 调度。如果部署到带 GPU 的边缘盒子,可以转 TensorRT;如果部署到手机,转 NCNN。这里有一个取舍:FP16 会掉一点精度,而柑橘成熟度这种连续视觉任务对精度敏感度很高,我建议先在 FP32 下验证准确率不掉,再考虑量化。我做过一个分选项目,把模型量化到 INT8 后,总体准确率掉了3个百分点,在成熟/未熟这种强差异上还能接受,但转色类直接崩了。所以量化前一定要看混淆矩阵每类的召回率,而不是只看总体准确率。
我自己的项目里,最终留在手机端的不是 ResNet,而是替换成 MobileNetV3,并在采集设备的白平衡上做了一层预处理,让输入图片的色温统一。成熟度识别比一般分类更依赖颜色,所以边缘端推理的预处理必须和训练时完全一致,尤其是 Normalize 的 mean/std,不能随手改。这也是我踩过最深的坑。
如果你从这个 zip 开始,按“拆数据→小CNN跑通→混淆矩阵分析→迁移学习→导出部署”的顺序走,遇到翻车的概率会小很多。模型精度不够时,第一反应永远是查数据,不是换更深的网络。希望帮到你。
本文还有配套的精品资源,点击获取