ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于CNN的土豆疾病识别:从图像分类到工程实践

基于CNN的土豆疾病识别:从图像分类到工程实践

搞土豆疾病识别这个毕设题目的同学,这几年是越来越多了。原因也很直白:CNN卷积神经网络做图像分类这套技术栈已经非常成熟,公开数据集好找,训练流程有章可循,而且农业病害识别这个场景天然有“讲故事”的空间——答辩的时候能说清楚社会价值,导师也愿意听。我前前后后帮人看过好几个类似的项目,自己也完整跑通过一套基于Python的CNN土豆疾病识别流程,这里把整条链路从头到尾拆开讲一遍,包括数据集怎么整、模型怎么调、训练踩过哪些坑、最后怎么包装成能演示的成品,希望对正在做或者准备做这个方向的你有实际帮助。

1. 项目定位与需求拆解

1.1 土豆疾病识别到底要解决什么问题

土豆种植过程中最头疼的就是病害。晚疫病、早疫病、疮痂病、黑痣病、干腐病、软腐病,每一种病的症状表现不一样,处理方式也完全不一样。晚疫病发病快、传播猛,一旦大面积爆发,减产可以到50%以上;早疫病虽然没晚疫病那么致命,但会导致叶片提前枯死,块茎发育受阻。传统做法是请农技人员下田诊断,或者靠种植户自己对照图谱看,效率低不说,经验不足的人很容易误判,等发现的时候往往已经错过了最佳防治窗口。

这个项目要做的,就是拿手机或者相机拍一张土豆叶片或者块茎的照片,交给我们训练好的模型,几秒钟内告诉你叶片上是什么病。本质上看,这是一个标准的图像分类任务——输入是图片,输出是病害类别。用深度学习、具体说用CNN卷积神经网络来做这件事,核心优势在于不需要人工设计特征。过去用传统机器学习做,你得先想办法提取颜色特征、纹理特征、形状特征,不同病害在不同生长阶段表现差异很大,人工特征很难覆盖全面。CNN是端到端学习,模型自己从海量图片里归纳出“晚疫病到底长什么样”,省掉了最费劲的特征工程环节。

1.2 毕设的定位和学习目标

作为毕业设计,这个项目的定位很清晰:做一个能跑通的完整系统,而不是只交一个模型文件。很多同学误区在于只关注训练集准确率,忽略了整个系统的完整性。实际答辩的时候,老师更关心的是你有没有完整的工程思维——数据怎么来的、模型怎么选的、效果怎么评估的、能不能演示给别人看。

一个合格的土豆疾病识别毕设,至少要包含以下环节:数据集构建与预处理、CNN模型的设计或选型、模型训练与调优、评估指标分析、可视化展示、以及一个能用的交互界面。整套做下来,你在Python编程、数据处理、深度学习框架使用、模型调参、Web开发这几个维度都会有实打实的提升。技术栈上推荐的就是Python + PyTorch(或者TensorFlow,后面我会对比选型),深度学习框架负责模型训练,Flask或者Streamlit做展示层。

注意,如果你的学校对毕设有明确格式要求,比如必须包含某某模块,一定要尽早看清楚题目要求再动手,别做到一半才发现方向偏了。

2. 数据集准备与预处理

2.1 数据从哪来:公开数据集与自行采集

做图像分类,数据是第一关。土豆疾病识别的数据来源主要有两个:

第一是公开数据集。国际上比较常用的是PlantVillage数据集,里面有土豆晚疫病、早疫病和健康叶片的图像,共约2000多张,背景相对干净,叶片占画面主体。这个数据集胜在好下载、标注规范、论文引用率高,用来做课程设计或者毕设的基线完全够用。不过它也有明显短板:图片太“干净”了,基本都是单一叶片对准镜头拍的,背景统一、光照均匀。实际田间的照片会有泥土、杂草、阴影、虫咬痕迹等多种干扰,模型在PlantVillage上表现很好,一放到自然场景就掉点严重。

第二是自行采集或扩充。想提高模型的实用价值,最好自己拿手机到田间、菜市场、种植大棚补拍一批照片。别小看这一步,哪怕是几张加了杂草背景的叶片图,对模型泛化能力的提升都可能是决定性的。数据不够的时候还可以考虑爬取网络图片,但要注意版权和标注质量问题,网络图什么分辨率、什么拍摄角度都有,清洗成本不低。

还有一个思路是数据扩充(Data Augmentation),这个放在下一节详细讲。但先给个底线:每个类别至少要有100张以上原始图片,再通过增强扩充到500到1000张每类,模型才有基本可用的效果。少于这个量级,train出来的模型基本是玩具,碰上新样本就露馅。

2.2 目录结构与数据标注

数据准备的第一步是建立规范的目录结构。我建议按下面的方式组织,不管后期用PyTorch的ImageFolder还是自己写DataLoader都方便:

potato_disease/ ├── dataset/ │ ├── train/ │ │ ├── Early_Blight/ │ │ │ ├── early_001.jpg │ │ │ └── early_002.jpg │ │ ├── Late_Blight/ │ │ └── Healthy/ │ └── val/ │ ├── Early_Blight/ │ └── Late_Blight/ └── weights/

这里有个细节值得说明:训练集和验证集一定要分开,而且要确保同一次拍摄、同一株土豆的照片不能同时出现在两个集合里,否则会造成数据泄漏(Data Leakage)。比如你拍了一株土豆的5张叶片,其中3张进了训练集、2张进了验证集,模型实际是“见过”这株土豆的,验证集分数就会虚高,答辩现场用真实的图片一测就露馅。我在帮人检查项目的时候就遇到过这种情况,训练准确率99%,验证准确率97%,换了一批网上找的真实土豆病害图来测,掉到71%,这就是典型的数据泄漏加上过拟合。

标注方面,如果用的是公开数据集通常已经分类放好了,不需要额外标注。如果是自己采集的图片,建议按文件夹名作为标签,文件名用类别_编号的形式,比如late_blight_001.jpg,这样后期排查数据问题非常方便。千万别用带中文的路径或者文件名,很多深度学习框架在处理中文路径时会出编码错误,不要在这个地方浪费时间。

2.3 图像预处理与数据增强

原始图片不能直接喂给CNN,需要先做标准化处理。一般流程是:

  1. 统一尺寸。CNN要求输入图片尺寸固定。我常用的是224x224,这个尺寸是VGG、ResNet等经典模型的标配,信息保留足够,显存消耗也可以接受。如果自行设计的网络比较小,128x128也能跑,但识别细小的病斑能力会弱一些。
  2. 归一化。像素值从0到255缩放到0到1,或者按ImageNet数据集的均值和标准差做标准化。使用预训练模型时,要跟着原模型的标准化参数走,不能自己随便改。
  3. 数据类型转换。转成PyTorch的Tensor格式,颜色通道从HWC转成CHW。

数据增强(Data Augmentation)是提升模型泛化能力性价比最高的手段。训练时做增强,验证集和测试集不做。常用的增强策略:

  • 随机水平翻转、垂直翻转:模拟不同拍摄角度
  • 随机旋转(正负15度):叶片在田间不会总是正立的
  • 随机裁剪:模拟局部观察
  • 颜色抖动(亮度、对比度、饱和度微调):模拟不同光照条件
  • 高斯模糊:模拟手机对焦不准的情况

PyTorch里面用torchvision.transforms组合即可,关键代码如下:

from torchvision import transforms train_transforms = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomRotation(15), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transforms = 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]) ])

有个很多人忽略的点:验证集虽然不做“随机”增强,但也不能直接resize到224,最好先Resize到256再CenterCrop成224。这样做的好处是保留更多的中心区域细节,避免图片被直接拉伸变形。不要小看这个细节,同样的模型,数据预处理方式不同,验证集准确率能差2到3个点。

3. CNN模型架构设计与选型

3.1 经典CNN结构拆解:卷积、池化、全连接

现在做土豆疾病识别,无外乎两条路:从头训练一个简单的CNN,或者用预训练模型做迁移学习。先讲从头训练的CNN,因为这是理解整个项目原理的基础,也是毕设论文里必须讲清楚的部分。

一个典型的CNN分类网络由三个核心组件堆叠而成:

**卷积层(Convolutional Layer)**是特征提取的主力。卷积核在图像上滑动,每个位置的局部区域和卷积核做点积运算,输出一个响应值。多个卷积核堆叠起来,就能提取不同的特征——浅层学到的通常是边缘、颜色、纹理等低级特征,深层学到的才是“病斑”“枯黄区域”这类语义特征。卷积层有两个核心参数:卷积核大小(通常3x3或5x5)和卷积核数量(输出通道数)。3x3卷积核是目前的主流选择,因为两个3x3卷积的堆叠感受野等同于一个5x5卷积,但参数量更少、非线性更强。

**池化层(Pooling Layer)**的作用是降采样。常用最大池化(Max Pooling),取窗口内最大值作为输出。它一方面缩小特征图的尺寸,减少计算量;另一方面让模型对小幅位移和形变更不敏感。叶片上的病斑位置不可能每次都出现在同一像素位置,池化层天然给了模型一点“容错空间”。

**全连接层(Fully Connected Layer)**在网络的尾部。卷积层和池化层把图片压缩成特征图,全连接层把这些特征展开、加权求和,最终通过Softmax输出每个类别的概率。以4分类(早疫病、晚疫病、健康、其他)为例,最后一个全连接层输出4个值,经过Softmax变成和为1的概率分布,取最大概率对应的类别作为预测结果。

3.2 迁移学习:为什么用ResNet、VGG能省事省力

对于毕设项目,我强烈推荐迁移学习方案。所谓迁移学习,就是拿别人在大规模数据集(比如ImageNet,包含上千万张自然图片、1000个类别)上预训练好的模型,借它的“眼睛”来做特征提取。预训练模型已经学会了通用的图像特征(边缘、纹理、形状等),我们只需要把它的末端换掉,接上一个针对土豆病害类别数的新分类头,再微调训练即可。

主流选择有:

模型参数量特点推荐度
ResNet18约1100万轻量、训练快、效果不错推荐
ResNet50约2500万更深的残差网络,容量大效果更好但更吃算力
VGG16约1.4亿经典但参数爆炸不推荐,性价比低
EfficientNet-B0约530万参数量少,精度高适合追求轻量部署

ResNet我格外推荐,原因是它引入了残差结构(Residual Block)。简单说,残差连接允许梯度“抄近道”直接流向前面的层,有效解决了网络层数加深后梯度消失的问题,让几十层甚至上百层的网络也能正常训练。对毕设而言,ResNet18是在精度和算力之间的最佳平衡点,单卡训练十几分钟就能出一个不错的结果。

在ImageNet上预训练的模型权重,PyTorch中直接一行代码就能下载:

import torchvision.models as models model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.fc = torch.nn.Linear(512, 4) # 替换最后的全连接层,4个类别

这里fc是ResNet18在全连接层之前的特征维度512,替换成输出4类的新全连接层。预训练权重里前几层负责提取通用特征(边缘、纹理),不需要大改动;新加的FC层因为初始化是随机的,需要更大学习率来训练,后面的微调学习率则要小得多。这个细节我在调参部分再展开。

3.3 从头训练与迁移学习的成本对比

我见过不少同学纠结要不要用预训练模型,总觉得“用别人的权重不算自己做”。这是典型的认知误区。工业界的做法几乎全都是站在预训练模型肩膀上再微调,你能用预训练模型在一个小数据集上解决实际问题,本身就是深度学习工程能力的体现。论文里把原理讲清楚、把对比实验做出来,完成度反而更高。

从实际数据看效果,在128张每类的土豆叶片数据上,从头训练一个自定义CNN的最佳结果大约在82%到88%之间;同样数据,用ResNet18做迁移学习,Fine-tune之后轻松到93%以上。差距的原因不难理解:我们自己设计的小网络没有见过足够多的图像,它学到的东西局限在训练数据里,泛化能力弱。而预训练模型已经在千万张图上学会了一个好的特征表征,哪怕土豆叶片和ImageNet里的图片分布有差异,这些通用特征依然有效。

算力方面,从头训练通常需要200到300个epoch才能收敛,迁移学习50个epoch以内就够了。如果你是在自己的笔记本上跑,这个差距在时间上体现得极为明显。

4. 训练配置、调参与完整流程

4.1 损失函数、优化器与关键超参数选择

模型结构定下来之后,训练配置决定了模型最终能到多好。先把几个关键超参数讲透:

损失函数:多分类任务的标准选择是交叉熵损失(Cross Entropy Loss)。PyTorch里的torch.nn.CrossEntropyLoss已经内置了Softmax计算,所以模型最后一层直接输出原始logits就行,不要在FC之后再手动加Softmax,否则会重复计算导致数值问题。

优化器:我推荐用AdamW,它是Adam的改进版,对权重衰减的处理更干净,训练过程中对学习率的敏感度比SGD低,更适合新手。公式不必死记,但要知道它的核心机制是给每个参数自适应的学习率,梯度大的参数步长小、梯度小的参数步长大。当然,如果追求极致的准确率,业界还是经常回过头用SGD配合动量项,在微调后期往往比Adam表现好一点点。对毕设来说,AdamW足够用了。

学习率:这是训练最需要花心思的超参数。迁移学习的经验法则是:新加的FC层用相对大的学习率,比如1e-3;预训练部分用较小的学习率,比如1e-4到5e-5。PyTorch中可以给不同参数组设置不同学习率:

fc_params = model.fc.parameters() base_params = [p for n, p in model.named_parameters() if n.startswith('fc') is False] optimizer = torch.optim.AdamW([ {'params': base_params, 'lr': 1e-4}, {'params': fc_params, 'lr': 1e-3} ], weight_decay=1e-4)

此外,学习率不能一直保持不变。前期希望大步快跑,后期希望小步精调,所以要用学习率调度器。我用得比较顺手的是余弦退火调度(Cosine Annealing):

scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=30, eta_min=1e-6 )

Batch Size:取决于显存大小。一般设为16或32。Batch size太小时梯度噪声大、收敛不稳;太大时显存装不下,且对学习率的配合要求更高。如果训练loss出现锯齿状抖动,先尝试降低batch size或降低学习率。单卡8GB显存跑ResNet18,batch size=32基本顶格了。

Epochs:迁移学习一般30到50个epoch足够。判断收敛的标志不是训练loss为0,而是验证集准确率连续多个epoch不再上升。一定要盯着验证集,训练集loss持续下降是正常的,验证集不涨就是问题了。

4.2 完整训练代码框架

下面给出一个完整的训练循环骨架,这是我实际用过的版本,精简了无关逻辑但保留了核心结构:

import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, models, transforms device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # 数据加载 train_dataset = datasets.ImageFolder('dataset/train', transform=train_transforms) val_dataset = datasets.ImageFolder('dataset/val', transform=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) # 模型 model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) num_ftrs = model.fc.in_features model.fc = nn.Linear(num_ftrs, len(train_dataset.classes)) model = model.to(device) # 损失与优化 criterion = nn.CrossEntropyLoss() fc_params = model.fc.parameters() base_params = [p for n, p in model.named_parameters() if not n.startswith('fc')] optimizer = torch.optim.AdamW([ {'params': base_params, 'lr': 1e-4}, {'params': fc_params, 'lr': 1e-3} ], weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=30, eta_min=1e-6 ) 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() * inputs.size(0) model.eval() val_correct = 0 val_total = 0 with torch.no_grad(): for inputs, labels in val_loader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) _, preds = torch.max(outputs, 1) val_correct += (preds == labels).sum().item() val_total += labels.size(0) epoch_acc = val_correct / val_total epoch_loss = running_loss / len(train_dataset) print(f'Epoch {epoch+1:02d} | Loss: {epoch_loss:.4f} | Val Acc: {epoch_acc:.4f}') if epoch_acc > best_acc: best_acc = epoch_acc torch.save(model.state_dict(), 'weights/best_model.pth') print(f'Best Val Acc: {best_acc:.4f}')

这个代码里有几个值得注意的细节:

第一,num_workers=4和pin_memory=True能明显加速数据加载。如果训练时发现GPU利用率时高时低,多半是数据加载卡了脖子,可以试试提高num_workers或者改用SSD存放数据。

第二,每个epoch结束一定要在验证集上做评估,并且只保存验证准确率最高的模型。不要在训练结束时才保存最后一个checkpoint,那个模型可能已经过拟合了。

第三,model.train()和model.eval()不能混。train模式会启用Dropout和BatchNorm的训练行为,eval模式会切换成推理行为。忘记切换是新手最常见的错误之一,导致验证结果异常。

4.3 训练可视化与进度监控

只靠打印loss和准确率,很难直观判断训练是否健康。所以要把曲线画出来。推荐两个方案:

方案一是TensorBoard。PyTorch自带torch.utils.tensorboard,在训练循环里周期性写入数据,浏览器打开localhost:6001就能看曲线。核心三行代码:

from torch.utils.tensorboard import SummaryWriter writer = SummaryWriter('runs/potato_experiment') # 在训练循环里 writer.add_scalar('Loss/train', epoch_loss, epoch) writer.add_scalar('Acc/val', epoch_acc, epoch)

方案二是直接用matplotlib画loss和准确率曲线,保存成PNG图。对于毕设来说,这两种都要有——matplotlib的图可以直接贴进论文,TensorBoard方便实时监控训练过程。

记住一个判断原则:训练loss下降但验证准确率不升,说明模型在学习训练集的特殊模式,这是过拟合信号;训练loss根本不降,说明学习率太大或者模型结构有问题;验证准确率抖动剧烈但整体不升,先检查数据加载是不是用了shuffle、验证集图片预处理是否正确。

5. 模型评估与实验对比

5.1 评估指标:准确率不是唯一的标尺

毕设答辩时,老师最常问的问题就是“你这个模型效果到底怎么样”。如果只拿一个总体准确率来回,显得单薄。专业的做法是用一组分类指标来评估,尤其当类别不平衡时,单纯准确率会产生误导。

以二分类(有病/没病)为例,先定义四个基本量:真正例(有病被识别为有病)、假正例(没病被识别为有病)、真负例(没病被识别为没病)、假负例(有病被识别为没病)。于是有:

  • 准确率(Accuracy) = (真正例 + 真负例)/ 总样本数
  • 精确率(Precision) = 真正例 / (真正例 + 假正例):查出来的里面有多少是对的
  • 召回率(Recall) = 真正例 / (真正例 + 假负例):实际有病的里面找回来多少
  • F1分数 = 2 * 精确率 * 召回率 / (精确率 + 召回率):两者兼顾的调和平均

土豆疾病识别场景里,召回率尤其重要。如果一个晚疫病叶片被漏诊成健康,农户以为没事,推迟了防治,可能导致大面积减产。所以宁可精确率稍微低一点,也要尽量把有病的样本都揪出来。

Sklearn里有现成的计算函数:

from sklearn.metrics import classification_report, confusion_matrix print(classification_report(y_true, y_pred, target_names=class_names)) print(confusion_matrix(y_true, y_pred))

5.2 混淆矩阵与Grad-CAM可视化

混淆矩阵是解读模型错误模式的最佳工具。比如一个4分类问题,混淆矩阵是个4x4的表,第i行第j列表示“实际是第i类但被预测成了第j类”的数量。通过混淆矩阵,你能快速定位模型最容易混淆哪两类——土豆早疫病和晚疫病在症状上本来就很接近,如果混淆集中在这两类之间,说明模型提取的特征还不够细。这个分析过程写进论文里,就是很好的实验讨论内容。

Grad-CAM(梯度加权类激活映射)是另一个必杀技。它通过计算类别分数对最后一层特征图的梯度,生成一个热力图,告诉我们模型到底是看叶片的哪个区域做判断的。一张晚疫病叶片的原图旁边,加上Grad-CAM可视化结果,如果高亮区域正好覆盖在病斑上,就说明模型学到的特征有医学依据;如果高亮区域在背景上,说明模型可能走偏了——比如靠学习叶片边缘的形状来分类,而不是靠病斑。这个可视化结论,答辩时拿出来极有说服力。

PyTorch实现Grad-CAM有一定代码量,网上的开源实现也不少。这里给一个思路:注册最后一层卷积层的正向和反向钩子,记录特征图和梯度,然后做全局平均池化得到权重,加权求和后过ReLU归一化。如果实在不想自己写,也有现成的pytorch-grad-cam库可以用。注意无论如何,用Grad-CAM做出来的结论要诚实——如果发现模型在高亮不该看的地方,千万不要为了好看强行使用,那反而是问题信号。

5.3 消融实验与对比实验设计

好的毕设论文不能只展示“我的模型最终达到多少准确率”,还需要回答“为什么达到这个准确率”。消融实验(Ablation Study)就是回答这个问题的方法。

推荐做三组对比:

第一组,不同骨干网络对比。ResNet18、ResNet50、EfficientNet-B0各跑一遍,记录验证集准确率、参数量、单张推理耗时。结论通常会落在“ResNet18在精度和速度之间平衡最好”,这就是你选它的理由。

第二组,数据增强有无对比。同一种模型,一组用增强、一组不用增强,对比验证准确率。一般结论是增强能提升3到5个点,同时缓解过拟合。

第三组,冻结特征提取层与全参数微调对比。冻结方案只训练新加的FC层,全参数微调是让预训练权重也跟着更新。全参数微调效果好,但需要的学习率和训练时间也不同。两组跑完,你能清楚地说明“为什么要微调而不是直接当特征提取器用”。

对比实验的关键是控制变量。别一组用了ResNet18加增强加余弦退火,另一组用ResNet50不加增强加固定学习率,这样差距说不清是哪个因素导致的。每一组只改一个变量,其他保持完全一致。

6. Web端展示与部署实战

6.1 Flask接口设计与推理服务

毕设最后一定要有一个能现场演示的界面。最轻量的方案是Flask。它简单、灵活,写一个接口接收图片上传,调用模型推理,返回结果,前后端加起来100行左右就能跑起来。

推理服务的关键代码:

import torch import torch.nn.functional as F from PIL import Image from torchvision import transforms from flask import Flask, request, jsonify app = Flask(__name__) device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = models.resnet18(weights=None) model.fc = torch.nn.Linear(512, 4) model.load_state_dict(torch.load('weights/best_model.pth', map_location=device)) model.to(device) model.eval() class_names = ['Early_Blight', 'Late_Blight', 'Healthy', 'Other'] infer_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]) ]) @app.route('/predict', methods=['POST']) def predict(): file = request.files['image'] img = Image.open(file.stream).convert('RGB') img_tensor = infer_transform(img).unsqueeze(0).to(device) with torch.no_grad(): outputs = model(img_tensor) probs = F.softmax(outputs, dim=1)[0] pred_idx = torch.argmax(probs).item() confidence = probs[pred_idx].item() return jsonify({ 'class': class_names[pred_idx], 'confidence': round(confidence, 4), 'probabilities': {name: round(p.item(), 4) for name, p in zip(class_names, probs)} }) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000)

几个关键点值得说明:

第一,推理时输入的预处理必须和验证集完全一致。很多同学模型训练得很好,最后部署时忘了改图片尺寸或者忘了归一化,出来的结果全是错的。建议把验证集用的transform函数原封不动复制过来。

第二,用model.eval()切到推理模式,并且用torch.no_grad()关闭梯度计算。这两件事能显著降低推理时的显存占用和计算开销,千万不要省。

第三,Image.open之后务必.convert('RGB')。因为有些手机拍摄的图片是RGBA四通道或者灰度图,不转换的话死活跑不通,报一个莫名其妙的通道数错误。

第四,单张图片的推理耗时通常在几十毫秒级别,用户几乎无感。但如果要在CPU上跑,ResNet18单张推理大概需要200到500毫秒,也能接受。想提速可以试试转成ONNX再推理,毕设阶段不是必需。

6.2 前端页面:让演示效果加分

前端不需要做得多炫,但要做到操作简单、反馈及时。推荐做一个单页面:左边是图片上传区域(支持拖拽和点击上传),右边是结果展示区——显示预测类别、置信度、各类别概率条形图。

Upload表单指向Flask的/predict接口,前端用JavaScript写个几十行的异步请求:

const formData = new FormData(); formData.append('image', fileInput.files[0]); fetch('/predict', { method: 'POST', body: formData }) .then(response => response.json()) .then(data => { document.getElementById('result').innerText = `识别结果:${data.class} 置信度:${data.confidence}`; });

如果想进一步省事,完全可以用Streamlit替代Flask加手写前端。Streamlit是Python生态里的一个Web应用框架,几十行代码就能做出一个带上传组件、图片显示、结果输出的页面:

import streamlit as st st.title('土豆疾病识别系统') uploaded = st.file_uploader('上传土豆叶片照片', type=['jpg', 'png', 'jpeg']) if uploaded is not None: image = Image.open(uploaded).convert('RGB') st.image(image, caption='上传图片', use_column_width=True) # 调模型推理 result = predict(image) st.write(f'**识别结果:** {result["class"]}') st.write(f'**置信度:** {result["confidence"]:.4f}')

Streamlit的好处是不用写HTML、CSS、JavaScript,语法简单,适合毕设快速出成品。如果你想省下更多时间调整模型效果,而不是折腾前端,优先选Streamlit。当然,如果你论文里有“系统架构”“前后端交互”这些章节,Flask方案的可写内容更丰富,两个方案各有取舍,按需选择。

6.3 部署到服务器或者跑在本机

毕设演示一般只需要本机运行,不必真的部署到云服务器。但要在答辩现场用别人的电脑跑,得提前把环境备好。建议写一个requirements.txt,把关键依赖和版本固定下来:

torch==2.1.0 torchvision==0.16.0 flask==2.3.2 pillow==10.0.0 numpy==1.24.3 streamlit==1.28.0 scikit-learn==1.3.0 matplotlib==3.7.0

再写一个简单的README.md,把项目结构、环境安装命令、训练命令、启动命令写清楚。这既是给答辩老师看的,也是给自己的存档。我见过太多人做完项目之后半年回头一看,发现自己都忘了怎么跑。

提醒一下,如果在演示现场用的是别人电脑,PyTorch的CPU版本安装包比较大(几百MB),建议提前下载好离线安装包放U盘里,别现场下依赖,答辩教室网络往往不给力。

7. 常见问题与排查技巧实录

7.1 数据类问题:样本不平衡与病斑遮挡

样本不平衡是土豆疾病识别最常遇到的数据问题。晚疫病和早疫病图片多,其他病害图片少,模型学到的是“多数类优先”,少数类几乎识别不出来。解决办法有三板斧:第一,对少数类做更狠的数据增强,把旋转范围加大、加更多颜色抖动;第二,在损失函数里给少数类加权重,CrossEntropyLoss的weight参数直接传一个按类别样本数反比的权重向量;第三,收集更多少数类样本,如果实在收集不到,退一步把少数类合并为“其他”类别,做成3分类,诚实说明问题比硬撑一个4分类效果差要好得多。

病斑在叶片上只占很小比例是另一个痛点。224x224的输入,如果病斑只有几个像素大,模型很难抓住。解决办法是在增强时加入随机裁剪的变体,让模型有机会看到病斑的特写。也可以考虑把输入分辨率提高到384x384,但训练时间和显存会相应上涨,需要权衡。

7.2 训练类问题:过拟合、梯度爆炸与loss异常

过拟合的典型表现:训练准确率一路涨到99%,验证准确率停在92%不再动,两边差距越来越大。缓解手段按效果排序:数据增强 > 早停(Early Stopping,验证集不涨就停止训练)> Dropout > 权重衰减。迁移学习模型因为容量大,在小数据集上过拟合更快,Dropout加在替换后的FC层之前会有帮助。

Loss变成NaN通常意味着学习率太大导致梯度爆炸,或者数据里有异常值。先降低学习率到1e-5试试,如果还炸,检查输入图片有没有损坏、有没有全黑的空图。同时加上梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0),可以兜底防止训练崩溃。

Loss不下降的原因多种多样。常见的有:学习率太小(卡在局部区域)、数据预处理出错(归一化参数填反了)、标签和图片错位(数据加载顺序和标签对不上)。最快的排查方式是用一小批数据(比如16张)先跑20步,看loss有没有下降趋势,没有就停止,不要浪费几小时跑完整epoch才发现问题。

7.3 部署类问题:模型加载失败与预测结果全一样

模型文件加载报错最常见的原因是torch.save和torch.load的模型结构不一致。比如你训练时用了4分类的FC层,部署脚本里却写了输出2分类,结构对不上自然报错。建议在保存时连同模型信息一起打包:

torch.save({ 'model_state_dict': model.state_dict(), 'class_names': class_names, 'num_classes': len(class_names), 'input_size': 224 }, 'weights/checkpoint.pth')

部署时先读元信息,再构建模型,就不会出现这类低级错误。

预测结果永远输出某一类,先检查验证集本身是不是这样——如果验证集里某类样本占绝对多数,模型可能学成“无脑投票”模式。用混淆矩阵看一下,如果某类精确率和召回率都极低,就是典型的类别不平衡导致。另一种可能是推理时的transform和训练不匹配,图片整体亮度变了,模型判断不了。用Grad-CAM看一眼模型关注区域,很容易定位问题。

7.4 毕设答辩避坑清单

最后整理一份答辩前的检查清单,每一项都是我见过别人踩过的坑:

  • 代码能不能在答辩电脑上直接运行?提前在另一台机器上跑一遍,确保没有依赖缺失。
  • 演示图片准备了吗?提前准备5到10张不同类别的测试图片,包括一张模糊些的、一张有杂草背景的,看看模型是否稳定。
  • 答辩PPT里有没有放混淆矩阵和Grad-CAM图?这两张图是体现“我真的理解模型”的最好证据。
  • 有没有准备“模型效果不好时怎么办”的答案?比如晚疫病和早疫病混淆严重时,可以说数据量不足、两类病斑早期形态相似,改进方向是采集更多数据、引入多尺度特征融合。
  • 不要夸大效果。诚实说明数据集的局限性、模型的局限性,比声称“准确率99%能落地商用”可信得多。

8. 项目扩展:还能往哪个方向做深

土豆疾病识别这个题目做到能交差很容易,但想做得有亮点、有价值感,有几条进阶方向值得考虑。如果你的毕设要求比较高,或者你想把它变成一个可以写进简历的项目,下面的方向都可以扩展。

8.1 从分类到检测:让模型告诉你病斑在哪

当前项目做的是图像分类——整张图给一个标签。但实际农技人员更想知道的是“病斑在哪个位置、面积多大”。这就要从图像分类升级到目标检测。YOLO系列是主流选择,训练数据需要标注框(bounding box),标注工具可以用labelImg或者CVAT。检测模型的输出是一组边界框加上置信度,能直接计算病斑覆盖面积占比,为防治决策提供量化依据。工程量比分类任务大不少,但完成度也高一个档次。

8.2 从单图到多模态:加入环境数据辅助判断

植物病害的发生和环境条件强相关——温度、湿度、降雨量都会影响病原菌的传播。如果只有图片特征不够,可以把温湿度传感器数据作为辅助特征,和图像特征拼接起来输入模型。这就是多模态学习的应用。毕设里做出这个方向,论文的新颖性会有明显提升,哪怕只是把气象数据简单拼接到全连接层前,都是一条完整的创新链路。

8.3 从研究到落地:移动端推理

CNN模型部署到手机上,让农户在田间打开App拍照就能识别,这是最贴近实际应用的方向。方案有两条:一是用ONNX Runtime Mobile在Android上推理,二是用Flutter加PyTorch Mobile插件。模型压缩方面可以用量化(Quantization),把FP32权重压缩到INT8,模型体积缩小到原来的四分之一,推理速度提升2到3倍,精度损失通常可控在1到2个点以内。毕设做到这个程度,就不是“作业”而是“产品原型”了。

8.4 移动端模型实际落地的一个补充说明

如果选择移动端部署,建议在PC端先用TensorRT优化一把,再把量化后的模型移植到手机。不用TensorRT的话,PyTorch Mobile也够用。需要注意,移动端的性能测试不能用PC的显卡推理时间来衡量,得在真机上跑,Android端建议用中低端机型测试,因为农户用的手机不会是顶配旗舰。我见过一个案例,模型在开发者的骁龙8系手机上推理只要28毫秒,换到一台百元机上了500毫秒,这个差距是真实存在的,提前测试才能心里有数。

写在最后的一点个人体会

做这个土豆疾病识别项目,我最大的感受是:深度学习项目的难点往往不在模型结构,而在数据和对细节的掌控。模型选型有成熟方案照着用就行,数据集整理、预处理、训练配置、评估可视化、部署展示这些环节,才是真正拉开完成度差距的地方。训练模型可能只需要半小时跑完,但把数据检查清楚、把每个实验设计明白、把每个异常现象的原因想透,才是一个毕设项目里真正值钱的部分。

如果你正在做这个题目,我给的建议是先小后大——先用少量数据把整条流程跑通,再做完整数据集训练和调优。很多同学一上来就跑全部数据,结果预处理写错、训练代码有bug,几个小时跑完才发现问题,非常浪费时间。先把流程通了,再追求精度,这是效率最高的路线。

最后再分享一个小技巧:训练过程中把每个epoch的验证集预测结果随机抽几张保存下来,和原图放一起看看。你很快会发现模型什么时候开始“看懂了”病斑——那个瞬间,比准确率数字涨到99%更让人有成就感。祝你的土豆识别项目顺利跑通,答辩加油。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进