简介:面向植物生长阶段识别任务,这套基于PyTorch的VGG分类代码,适合图像分类入门者、深度学习者以及有植物表型分析需求的学生使用。资源不包含数据集图片,下载后只需自行收集图片,按类别放入对应文件夹即可开始训练,且可通过新建文件夹自由扩展识别类别。三个Python脚本分别负责生成数据索引、训练CNN模型、提供PyQt可视化界面,每一行都有中文注释,配合环境依赖清单和docx说明文档,从环境配置到模型训练再到界面展示的流程完整,小白也能顺畅理解。压缩包共9个文件,包含4张类别提示图、3个Python脚本、1个环境配置txt和1份说明文档,整体仅227KB,体量小巧、结构清晰。目前已有128人学习下载,适合用于入门实践、课程设计或轻量级植物分类实验,也可作为其他图像分类任务的代码改造基础。
1. 拿到"VGG模型-植物生长阶段分类"压缩包之后:先想清楚它解决什么问题
做农业表型分析或者智慧农业项目时,"植物现在长到哪个阶段了"是个高频又麻烦的问题。人工蹲在田里看苗情,一天下来眼睛都花了,而且不同人判断标准还不一样——同一片叶子,有人说营养生长,有人说快开花了,数据一打架,后面产量预测全乱。所以用深度学习图像识别来做这件事,本质上是把"看叶子、看植株形态"这种经验判断,变成一套可复现的分类标准。
这个压缩包的核心是 VGG 模型,任务是把植物的生长阶段做分类,包里没有数据集图片,只有代码和说明文档,代码带逐行注释。这样的结构对两类人最有用:一类是刚入门深度学习、想拿一个完整分类任务练手的学生,另一类是正在搭植物表型采集流程、需要一个可解释分类基准的从业者。你需要准备的是自己的植物图片,按照包的目录结构放进去,跑通训练流程,拿到一个能用的阶段分类权重。
2. 为什么植物阶段分类用 VGG 而不是更轻的网络:从卷积结构到特征选择
2.1 VGG 的堆叠结构到底在堆什么:卷积、激活和池化的配合
VGG 的思路非常直白,按"卷积块 + 全连接"的骨架来搭。每个卷积块由连续几个 3×3 卷积层组成,后面接一个 ReLU 激活,再跟一个 2×2 最大池化。这里的关键在于堆叠多个 3×3 卷积来等效一个大感受野。两层 3×3 卷积堆起来感受野等于一层 5×5,三层等于一层 7×7,但参数更少、非线性更强。
在植物阶段分类这个场景里,这个结构正好适合。以番茄为例,幼苗期叶片边缘是光滑的,营养生长期叶片开始出现缺刻,开花期能看到花序形态,结果期果实颜色和枝条弯曲角度都变了。这些特征从纹理到轮廓再到颜色分布,跨度很大,VGG 的浅层卷积抓边缘和纹理,深层卷积把局部纹理组合成完整的植株结构,最终在全连接层里做出阶段判断。
import torch.nn as nn def make_vgg_block(in_channels, out_channels, num_convs): layers = [] for i in range(num_convs): layers.append(nn.Conv2d( in_channels if i == 0 else out_channels, out_channels, kernel_size=3, padding=1 )) layers.append(nn.ReLU(inplace=True)) layers.append(nn.MaxPool2d(kernel_size=2, stride=2)) return nn.Sequential(*layers)这段代码定义了一个标准 VGG 卷积块。num_convs=2表示两层卷积加一个池化,num_convs=3就是三层。注意padding=1不能省,否则 3×3 卷积会让特征图尺寸逐渐变小,到后面全连接层时维度就对不上了。VGG16 的完整结构就是 5 个这样的块,输出通道数依次为 64、128、256、512、512。
特征图经过 5 次池化后缩小 32 倍,输入 224×224 的图片最终得到 7×7×512 的特征图,再展平送入全连接层。全连接层是 VGG 参数的大头,占了总参数量接近 80%,这也是 VGG 模型体积大的主要原因。很多优化方案会先砍掉前两个全连接层的部分神经元,或者把 4096 降到 1024,但代价是分类精度会有轻微下降。
2.2 阶段分类任务的特殊性:颜色、纹理和轮廓谁说了算
植物阶段分类和通用物体分类最大的区别在于:类别之间的差异往往不在"有没有这个物体",而在"同一个物体的状态变了"。深度学习课本里常见的猫狗分类,猫和狗的结构差异是本质性的。但植物苗期到营养期,叶子都是绿的、形状变化很小,差异落在叶片数量、茎秆粗细、株高这些连续变化的量上。
实际项目中最常见的问题是只拍叶片特写。单片叶子的纹理和颜色在相邻阶段之间差异极小,模型接收到的判别信息严重不足。做数据采集时,同一株植物至少要拍三张:整株全景、顶部新叶特写、茎秆和花/果部位。全景提供整体株型信息,新叶特写提供生长活跃度,花果部位提供生殖发育阶段信息。三个视角合在一起,VGG 才能从不同层级的特征中找到可靠的分类依据。
VGG 不擅长处理细粒度差异,但"三个视角输入 + 数据增强"能在很大程度上弥补这一点。随机裁剪、水平翻转、轻微旋转、色彩抖动这四个增强手段,能让模型看到同一株植物在不同角度和光照下的形态,逼迫它学到不变特征而不是记住某张图的偶然颜色。
2.3 对比 ResNet 与 MobileNet:VGG 在这个项目里的真实选型价值
很多人第一反应是:现在都什么年代了,还用 VGG?ResNet 有残差连接,MobileNet 参数少,为什么不用?这个质疑在工业部署场景完全成立,但在植物阶段分类这个任务上有例外。
最核心的原因是可解释性和梯度流。VGG 的层与层之间是严格顺序的,没有捷径连接,这带来一个看似缺点实为优点的性质:梯度的传播路径非常清晰,每个卷积块学到什么特征可以逐层可视化。当你需要向农业专家解释"模型为什么把这个样本判成开花期"时,VGG 的特征图能直接展示出模型关注的是花序区域还是叶片纹理,而 ResNet 的残差分支会让这种归因变得模糊。
另一个现实原因是训练稳定性。植物的阶段分类数据量通常不大,一个品种可能只有几千张图。在这个量级下,ResNet 的残差结构容易在训练后期出现过拟合,VGG 由于参数多、正则效果反而好控制。MobileNet 的深度可分离卷积在 CPU 和边缘设备上有速度优势,但分类精度在细粒度任务上会掉 2 到 4 个百分点,对阶段判断来说这个误差太奢侈了。
如果是新项目从零开始,我的建议是:先拿 VGG16 跑通整个流程,确认数据和标注没有问题,再用 ResNet50 做精度对比。如果 VGG 的准确率在 85% 左右,ResNet50 可能也就 87%,不值得为此付出迁移和调试成本。VGG 在这个场景里是"稳定基线"而非"过时网络",这一点拿到代码后要先想明白。
3. 把项目跑起来:从数据整理、标注到训练出第一个可用权重
3.1 数据端没有图片怎么办:目录结构、VGG Image Annotator 标注与训练集划分
压缩包说明文档里没有数据集图片,只有代码,这意味着你要自己准备数据。目录结构是 VGG 代码最容易出问题的地方,大部分报错都源于目录没有按预期组织。
plant_stage_data/ ├── train/ │ ├── seedling/ # 幼苗期 │ ├── vegetative/ # 营养生长期 │ ├── flowering/ # 开花期 │ └── fruiting/ # 结果期 ├── val/ │ ├── seedling/ │ ├── vegetative/ │ ├── flowering/ │ └── fruiting/ └── test/ ├── seedling/ ├── vegetative/ ├── flowering/ └── fruiting/四个阶段是常见划分,你可以按作物的实际生长周期调整。每个子目录下直接放 JPG 图片,命名随意,但不要带空格和中文。VGG 代码里的ImageFolder类会直接把子目录名作为类别名,所以目录名必须和类别一致,否则训练的权重对应关系会错乱。
如果你手里的图片没有按阶段分好,就需要先标注。最常见的标注工具是 VGG Image Annotator,它是个网页版工具,不需要安装。打开后把图片拖进去,画框或者画点,为每个标注对象指定类别,最后导出 JSON 文件。这个工具生成的 JSON 格式和你代码包里的数据处理脚本需要一致,如果字段对不上,说明文档里一般会给出对应的转换脚本。
python split_data.py --source ./raw_images --output ./plant_stage_data --train_ratio 0.7 --val_ratio 0.2数据划分的比例上,训练集 70%、验证集 20%、测试集 10% 是稳妥的默认值。注意不要让同一株植物的多张照片同时出现在训练集和验证集里,否则模型相当于提前看到了答案,验证集指标会虚高 5 到 8 个百分点。
3.2 加载与预处理:用 ImageNet 均值和随机增强提升泛化
数据准备好之后,看代码包里的数据加载部分。VGG 模型的预训练权重是在 ImageNet 上训练的,输入图片需要减掉 ImageNet 数据集的 RGB 均值,再做标准差归一化。这一步不做,权重初始化的统计分布和你的输入分布对不上,loss 会震荡,收敛速度明显变慢。
import torchvision.transforms as transforms from torchvision import datasets train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.6, 1.0)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(10), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.05), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_dataset = datasets.ImageFolder(root='./plant_stage_data/train', transform=train_transform) val_dataset = datasets.ImageFolder(root='./plant_stage_data/val', transform=transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]))这里有两个参数值得注意。RandomResizedCrop的scale=(0.6, 1.0)表示每次随机裁剪原图的 60% 到 100%,这个范围比通用分类任务要保守,因为植物阶段分类里如果裁剪太多,会丢掉判断阶段关键的茎秆或花序信息。验证集不要做随机增强,只用Resize(256)加CenterCrop(224)保持评估结果的可复现性。
数据加载器里还有一个容易被忽略的点:num_workers参数。Windows 上设为 0 最稳妥,Linux 上可以设成 CPU 核数的一半。设高了加载速度快,但训练到一半偶尔会卡在内存分配上,报错信息乱七八糟,排查半天才发现是数据加载线程的问题。
3.3 训练主循环里的 6 个关键代码点:从学习率到 checkpoint
训练脚本是代码包的核心,逐行注释通常会把网络定义、前向传播、反向传播、模型保存都标清楚。这里挑出几个必调的参数和一个完整的训练循环模板。
import torch import torch.nn as nn import torch.optim as optim from torchvision import models from torch.utils.data import DataLoader device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = models.vgg16(pretrained=True) # 冻结前三个卷积块,只训练后两个块和全连接层 for name, param in model.features.named_parameters(): if name.startswith('0') or name.startswith('3') or name.startswith('6'): param.requires_grad = False # 替换最后的全连接层,输出4个类别 num_features = model.classifier[6].in_features model.classifier[6] = nn.Linear(num_features, 4) model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=1e-4, momentum=0.9, weight_decay=5e-4) scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=3) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False) best_acc = 0.0 for epoch in range(30): model.train() running_loss = 0.0 for inputs, labels in train_loader: inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() model.eval() correct, total = 0, 0 val_loss = 0.0 with torch.no_grad(): for inputs, labels in val_loader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) loss = criterion(outputs, labels) val_loss += loss.item() _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() val_acc = correct / total scheduler.step(val_loss) if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'best_model.pth') print(f'Epoch {epoch+1}: saved, val_acc={val_acc:.4f}')这段代码里的关键决策点拆开说:
冻结前三个卷积块。植物图片的底层特征(叶子边缘、叶脉纹理)和 ImageNet 上学习到的特征高度相似,不需要重新训练。只训练后面两个卷积块和全连接层,能达到接近全部训练的效果,但训练时间少一半,过拟合风险也小。如果你的数据集只有一两千张,冻结前四个块、只训练最后一个块和全连接层更稳。
学习率设为 1e-4。VGG16 加预训练权重时,用 1e-2 这种常用学习率会直接把灾难性遗忘拉满——模型的预训练特征被冲掉,loss 飙升后降不回来。SGD 比 Adam 在这种迁移学习场景下更容易调到好结果,momentum=0.9, weight_decay=5e-4是标准配置。如果你想用 Adam,建议把学习率降到 1e-5。
ReduceLROnPlateau 的 patience=3。验证集 loss 连续 3 个 epoch 不降,学习率就减半。植物数据量小时,这个策略比固定 epoch 衰减学习率更省心,不用手动盯着曲线调时机。
保存条件用验证集准确率而非训练 loss。训练 loss 低不代表泛化好,尤其在全连接层参数量巨大的 VGG 上,过拟合是常态,验证集上的表现才是可信指标。每轮都保存 best_model.pth,不用纠结总训练轮数,30 轮不够就加到 50,反正只保留最佳权重。
4. 植物阶段分类最容易翻车的 5 个坑:现象、原因与解决
4.1 症状识别和阶段分类被混为一谈:数据标注的是"阶段"还是"状态"
项目里遇到最典型的问题是:植物得了叶斑病,叶片上有一块块黄褐色斑点,标注的人把这些图归到"营养生长期"。但从模型的角度看,它学到的是"有斑点的叶片 = 营养生长期",导致正常叶片的营养生长期图片反而被误判。这是因为标注者混淆了生长阶段和健康状态这两个不同的标签维度。
解决的唯一办法是先把标签定义写死。阶段分类只能标植株的发育状态,症状是另一个独立的分类任务。如果图片里出现了病虫害、枯叶、机械损伤,要么单独建一个"异常状态"类别,要么在标注时把这些样本剔除。B 站上很多人踩过这个坑,跑出来的模型准确率很高,但实际去田里验证时发现模型只会区分"叶子有没有病斑",阶段判断是错的。
4.2 样本类别不平衡,loss 一直不掉
植物的不同生长阶段持续时间不一样。番茄幼苗期只有两三周,结果期能持续两三个月。按时间等频率采样的话,结果期的图片数量可能是幼苗期的 5 倍以上。VGG 在这种不平衡数据上会把多数类学得很准,少数类几乎全错,整体的准确率看起来还行,但每一类的准确率天差地别。
看训练日志可以提前发现这个问题:如果某个类别的准确率始终低于 30%,而其他类超过 90%,基本就是不平衡在作怪。处理方案有两个,优先做数据增强扩充少数类,比如幼苗期图片多做几次随机裁剪和旋转,再看效果;不够的话再在损失函数里加类别权重:
class_weights = torch.tensor([1.0, 0.6, 0.5, 0.3]).to(device) criterion = nn.CrossEntropyLoss(weight=class_weights)这里权重的计算原则是"多数类权重低、少数类权重高",具体数值用最大类样本数除以每个类的样本数,再归一化到 0.3 到 1.0 之间。加了权重之后,模型对少数类的敏感度会提升,但要注意训练轮数需要适当增加,因为加权重后的 loss 比不加权重更难收敛。
4.3 224×224 输入尺寸对叶片细纹不友好:信息丢失的确定量化
作物研究人员会拿着显微镜级的叶片照片来问,为什么模型总把两个相邻阶段搞混。VGG 的标准输入是 224×224,而叶片上的细纹和毛状体等微观特征在这个分辨率下会被压缩得完全看不出纹理差异。模型相当于用一双近视眼在看叶片,怎么可能分得清。
要量化信息丢失程度,可以在预处理阶段做个简单实验:把同一张图缩放到 224×224,再放大回原尺寸,计算局部纹理区域的像素差异。通常会发现叶脉交叉处和腺毛覆盖区的像素平均偏差能到 15 以上。这说明问题不在模型而在输入。改进方向有两个:一是把 VGG 的输入调到 384×384,同时把第一个卷积层的 stride 从 2 改为 1。二是别指望单张叶片特写就够用,给模型同时输入叶片特写和整株轮廓图。前者改了结构要重新调参,后者只改数据加载逻辑,风险小得多。
4.4 训练 loss 正常但验证准确率低:VGG 全连接层的过拟合信号
这个现象非常常见:训练集准确率一路冲到 99%,验证集准确率卡在 60% 徘徊。VGG 的参数量超过 1 亿,其中大部分集中在三个全连接层。如果你的训练集只有一两千张图,全连接层会直接背诵训练集内容——说"背诵"并不夸张,模型不是学到了植物阶段规律,而是记住了这张图长什么样。
解决的手段按优先级排序:先加数据增强,把翻转和随机遮挡的强度加大;再做全连接层的 dropout 调参。VGG16 默认在全连接层之间有 dropout=0.5,但很多人加载预训练权重后改了分类头却忘了重新加 dropout。如果这两步都做了,验证准确率还是低,就缩小全连接层尺寸,把 4096 改成 1024,或者干脆去掉第一层全连接、让特征图直接接分类层。牺牲一点精度换泛化能力,在数据量有限时是划得来的。
4.5 依赖测试集单一指标做结论,忽略了 VGG 的置信度校准问题
有时候模型在测试集上准确率 90%,但部署到实际环境中误判率明显更高。一个常见原因是:开发时只看了 top-1 准确率,没有检查模型输出的置信度是否合理。VGG 的 softmax 输出天生过度自信,即使分类错误,给错误类别的置信度也可能高达 0.9 以上。
这类问题的排查方法是画一张置信度直方图,统计预测正确的样本和预测错误的样本分别在置信度 0.9 以上的占比。如果错误样本里也有大量高置信度输出,说明模型校准有问题。经典做法是通过温度系数来校准置信度:训练完成后额外收集一批验证集样本,用交叉熵损失搜索最优的温度缩放系数,推理时让 softmax 输出除以该系数。这个技巧几乎不增加推理开销,但能让置信度与真实正确率对齐。
5. 让训练好的 VGG 在本地上位:迁移学习改全连接层、特征可视化与置信度验证
拿到这个压缩包的价值不只在于训练一个模型,还在于把它变成你的调试基准。我的习惯是训练完成后不要急着收工,先做三个验证动作,确认模型不是在碰运气。
第一个动作是特征图可视化。取一张真实场景里拍摄的苗期照片,把它喂给模型,逐层打印第四个卷积块的输出特征图。将 512 张特征图按通道做平均后缩放回原图大小,叠加在原图上观察激活区域分布。如果激活区域主要落在叶片边缘和叶脉上,说明模型确实在"看结构";如果激活区域分散在背景土壤或无意义的区域,检查一下是不是背景干扰太强,考虑数据采集时统一拍摄背景颜色。
第二个动作是置信度阈值调整。模型在对单株植物做阶段判断时,经常出现两个阶段概率接近的情况,比如开花期概率 0.48、结果期 0.45。这种样本无论怎么训练都会存在,强行让它归类只会制造噪声。我一般会引入一个"低置信度拒绝判定"策略:
def predict_stage(model, image, threshold=0.7): model.eval() with torch.no_grad(): logits = model(image.unsqueeze(0)) probs = torch.softmax(logits, dim=1).squeeze(0) max_prob, pred_idx = torch.max(probs, 0) if max_prob.item() >= threshold: return class_names[pred_idx.item()], max_prob.item() else: return 'uncertain', max_prob.item()当最高类别的置信度低于 0.7 时,输出uncertain并不做自动判定,把这张图丢回人工复核队列。阈值设到 0.9 可以只保留高置信度的结果,适合自动化分析管道;如果人工复核成本低,0.6 以下的都交给人工也行。这个策略对农业分析特别重要——一个不确定的判断会顺着下游预测模型产生连锁误差。
第三个动作是类别间相似度检查。把验证集所有图过一遍模型,提取全连接层倒数第二层的特征向量,做 t-SNE 降维可视化。如果四个阶段在二维平面上是四个明显的簇,说明类别边界清晰;如果其中两个阶段混在一起成一团,说明这两类的训练样本在视觉上确实难区分,需要回到数据采集端补图。
做植物阶段分类项目时我一直有个习惯:每次训练完模型,先把best_model.pth、代码脚本、数据划分记录和一张 t-SNE 图放在同一个目录下归档。这样 3 个月后回来复现,不需要猜当时用的数据是哪些图、阈值设了多高。把这个习惯保留下来,你会发现 VGG 这个压缩包的价值远超一次课程作业——它是你后续接入无人机巡检、温室自动化管理等复杂系统的第一个可靠基线。希望这篇文章的操作思路能帮到你,少走几步我走过的弯路。
本文还有配套的精品资源,点击获取