简介:面向计算机专业毕业设计及深度学习入门者的农作物病虫害识别检测系统,基于卷积神经网络实现图像分类与病害检测,提供可直接运行的Python源码与配套数据集,解决从模型训练到检测部署的全流程问题。整个压缩包为88.27MB,共56个文件,其中包含Python脚本、9个Jupyter Notebook(涉及TensorFlow、PyTorch、Keras、Fastai等框架)、模型权重文件、YAML/yml部署配置、HTML前端页面以及Markdown说明文档等,能够覆盖数据预处理、模型训练、精度评估与Web端展示等环节。代码已经过严格调试,本地编译可运行,且附带Dockerfile、部署指南等辅助内容,适合快速复现项目或迁移到自己的毕业设计中。目前已有349人学习下载,对于需要完整项目经验的学生而言具有较高参考价值,同时也能帮助理解多种深度学习框架在农业场景下的应用方式。
1. 从病叶到检测框:这个农作物病虫害识别系统不只是“分类器”
一张带着零星病斑的水稻叶片照片丢进系统,几秒后返回一条记录:“稻瘟病,置信度0.92,位置在图片左下角那个框里”——这就是标题里那套“基于深度学习卷积神经网络的农作物病虫害识别检测系统”真正在做的事。它用Python组织数据、训练和推理,配一份标注好的数据集,把“这图有什么病”升级成“病在哪一块、有多大、属于什么病”。很多第一次做这个方向的人以为它是个图像分类项目,实际上检测比分类在农业场景里实用得多,因为田间病斑经常只占叶片很小一部分,整图分类很容易被大面积的健康背景带偏。这篇文章适合正在做同类毕业设计、以及想快速搭一套可复现识别检测原型的开发者。
2. 为什么是卷积神经网络:从图像特征到病虫害“找得到”
2.1 分类和检测是两回事:你的毕设到底该做什么
标题里“识别检测”四个字,背后是两条技术路线。图像分类的输入是一整张图,输出是一个类别标签,比如“稻瘟病”或“健康”。目标检测的输出则是若干个边界框,每个框有自己的类别和置信度。病虫害在田间不是铺满整张图的,往往是一片叶子上长了两块斑,旁边还有阴影和泥土干扰。你要是做整图分类,卷积神经网络很容易学到“背景越绿越像健康”这种假特征,最后在复杂稻田照片上翻车。检测的思路是先把病斑当独立目标“抠”出来,再做分类,所以它对位置、尺寸、重叠遮挡的鲁棒性都更好。
从毕业设计答辩的角度看,检测模型能画框,结果天然比一个分类准确率数字更有说服力。评委会问“你这系统到底识别出了什么”,你直接把带框的测试图放出来,比念一段指标强得多。但这不意味着所有情况都要上检测。如果你的数据本身就是裁剪好的病斑特写,每张图只有一种病、背景干净,那么分类网络也能做得很好,强行上检测反而增加标注成本。我的判断标准很简单:打开十张原始图,看病斑面积占整张图的平均比例,低于20%就果断走检测路线,标题里“检测”两个字也才站得住。
2.2 卷积神经网络如何把病斑“抠”出来
要理解为什么卷积神经网络是这套系统的核心,得先看它跟传统手工特征的做法差在哪。传统方法要人工设计颜色、纹理、形状特征,病斑种类一多,特征组合就爆炸。卷积神经网络不用你手工定义特征,它靠堆叠卷积层自动学习。前几层学到的是边缘、颜色块这类通用图案,中间层学到纹理和局部形状,后几层才组合出“这种斑点和周围健康组织完全不同”的高层语义。这个逐层抽象的过程,正好和病斑识别需要的“先找局部异常,再判断类型”天然对齐。
具体到网络里面,卷积核在图像上滑动,每个位置做一次加权求和,相当于在找图片上“最像这个特征模板”的区域。池化层不断缩小特征图尺寸,让模型对病斑在叶片上的位置不那么敏感,这就是平移不变性。很多教程把卷积核讲得很玄学,其实你可以把它想象成一组可学习的病斑模板,训练就是在调整这些模板里的数字,直到它们能激活真实病斑所在的区域。这也是为什么用预训练权重比自己从头训快得多——预训练权重里已经有大量边缘和纹理模板,缺的只是最后那层“什么样的斑点算病斑”的组合方式。
2.3 从VGG到YOLO:轻量级骨干网络更适合农业现场
卷积神经网络的结构选择,直接影响这套系统能不能在普通电脑上跑起来。经典VGG系列卷积层很深,精度不错,但参数集中在全连接层,一张512x512的图在CPU上推理要好几秒,不适合做实时的田间检测。ResNet引入残差连接,解决了深层网络梯度消失,但ResNet50又太重。农业场景里最常见的设备就是带个普通GPU的台式机,甚至可能是只有CPU的笔记本,轻量级骨干在速度和精度之间更实用。
我一般会在YOLOv5s和YOLOv8s之间选一个当基线,这两个模型本身就把骨干网络、检测头和训练策略封装好了,不需要自己再一行行写卷积层。YOLOv5s的骨干在640x640输入下,GPU推理只要几十毫秒,CPU也能扛住。如果你还想更轻,可以把骨干换成MobileNetV3或者ShuffleNet,但那样要手动改模型结构,工作量大了不少。对毕设来说,直接用成熟目标检测框架做二次数值训练,把省下的时间拿来做数据和调参,性价比最高。
| 骨干网络 | 参数量 | 典型推理速度 | 适用场景 |
|---|---|---|---|
| VGG16 | 138M | 慢 | 图像分类demo,不推荐用于检测 |
| ResNet50 | 25M | 中等 | 精度优先,硬件较好的场景 |
| YOLOv5s | 约7M | 快 | 病虫害检测默认首选 |
| MobileNetV3 | 约4M | 很快 | 移动端或CPU推理 |
2.4 数据集是系统的“隐藏得分项”:公开数据与自标注
数据集决定模型准确率的天花板。公开的病虫害数据集大多是分类形式,一张叶子特写配一个类别,而检测系统需要的是“图片+边界框+类别”三件套。有些公开数据集自带框,但农业类的很少。所以很多毕设会选择自己用LabelImg或者X-AnyLabeling标注几百张图,这完全够用,前提是照片质量和类别分布要控制好。
我拿到图片后第一件事就是统计每个类别的样本数量,避免训练到一半才发现某个类别只有十张图。下面这段Python脚本遍历分类文件夹,统计常见图片格式的文件数量:
import os from collections import Counter img_ext = {'.jpg', '.jpeg', '.png', '.bmp', '.JPG'} def count_images(root): counter = Counter() for cls_name in os.listdir(root): cls_path = os.path.join(root, cls_name) if not os.path.isdir(cls_path): continue files = os.listdir(cls_path) n = sum(1 for f in files if os.path.splitext(f)[1] in img_ext) counter[cls_name] = n return counter if __name__ == '__main__': data_root = 'data/raw' for cls_name, num in count_images(data_root).items(): print(f'{cls_name}: {num}张')这段代码逻辑很直接:遍历raw目录下的每个类别文件夹,用后缀过滤出图片,按类别计数。运行后你能立刻看到数据分布,是“大类别300张、小类别20张”还是“整体均衡”,后面的训练策略就跟着这个结果走。不要嫌这个步骤简单,我见过太多项目直接训练,最后发现小类别完全学不出来,还得回来补数据。
3. 用Python把数据集跑起来:标注格式、训练脚本与最小实现
3.1 目录结构与标签格式:让YOLO认你的数据
训练检测模型前,先要把数据整理成目标检测框架认识的目录结构。以YOLO格式为例,常见的目录长这样:
datasets/ crop_insect/ images/ train/ val/ labels/ train/ val/ crop_disease.yaml图片放在images下,标签放在labels下,train和val一一对应。每张图片的同名txt文件里,每一行代表一个目标,格式是“类别id 中心点x 中心点y 框宽 框高”,所有坐标都归一化到0到1之间。这个格式再看一遍很啰嗦,但几乎所有主流检测框架都认它,所以值得花十分钟把数据转成这个格式。
如果你用的是LabelImg,默认导出的是Pascal VOC格式的XML,需要转一下。下面这段脚本读取xml里的坐标和类别,换算成归一化坐标后写到txt里:
import xml.etree.ElementTree as ET import os def voc_to_yolo(xml_file, class_names, out_dir): tree = ET.parse(xml_file) root = tree.getroot() size = root.find('size') img_w = int(size.find('width').text) img_h = int(size.find('height').text) txt_name = os.path.splitext(os.path.basename(xml_file))[0] + '.txt' lines = [] for obj in root.iter('object'): cls = obj.find('name').text if cls not in class_names: continue cls_id = class_names.index(cls) box = obj.find('bndbox') x_min = float(box.find('xmin').text) y_min = float(box.find('ymin').text) x_max = float(box.find('xmax').text) y_max = float(box.find('ymax').text) x_center = (x_min + x_max) / 2 / img_w y_center = (y_min + y_max) / 2 / img_h w = (x_max - x_min) / img_w h = (y_max - y_min) / img_h lines.append(f'{cls_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}') with open(os.path.join(out_dir, txt_name), 'w', encoding='utf-8') as f: f.write('\n'.join(lines))这里最关键的一步是坐标归一化。VOC里存的是像素坐标,YOLO要求相对图片宽高的比例,所以先用xmin和xmax算出中心点x,再除以图片宽度。图片尺寸必须从xml的size节点读,不能写死,否则不同分辨率的图片转换后全部错位。脚本里还做了类别过滤,class_names列表之外的标注会被跳过,这个机制在处理“unknown”这种脏标注时很有用。
3.2 数据划分:按场景切分,别让一张叶子的照片同时出现在两边
数据划分看起来只是按比例分文件,里面的坑其实很隐蔽。如果同一株植物的多张照片被随机分成训练集和验证集,模型在训练时已经“见过”这片叶子的背景,验证时再遇到它,mAP会虚高,真正到新场景测试时马上现原形。正确做法是按场景或者按拍摄对象分组。
下面这段脚本按文件前缀分组划分数据,适合文件命名包含现场编号的情况:
import os import random import shutil random.seed(42) src_img_dir = 'datasets/crop_insect/images/all' src_lbl_dir = 'datasets/crop_insect/labels/all' train_img_dir = 'datasets/crop_insect/images/train' val_img_dir = 'datasets/crop_insect/images/val' train_lbl_dir = 'datasets/crop_insect/labels/train' val_lbl_dir = 'datasets/crop_insect/labels/val' groups = {} for img_file in os.listdir(src_img_dir): if not img_file.lower().endswith('.jpg'): continue prefix = img_file.split('_')[0] # 例如 field01_001.jpg -> field01 groups.setdefault(prefix, []).append(img_file) all_groups = list(groups.keys()) random.shuffle(all_groups) split_idx = int(len(all_groups) * 0.8) train_groups = set(all_groups[:split_idx]) val_groups = set(all_groups[split_idx:]) for group, img_files in groups.items(): dst_img = train_img_dir if group in train_groups else val_img_dir dst_lbl = train_lbl_dir if group in train_groups else val_lbl_dir for img_file in img_files: base = os.path.splitext(img_file)[0] shutil.copy(os.path.join(src_img_dir, img_file), os.path.join(dst_img, img_file)) shutil.copy(os.path.join(src_lbl_dir, base + '.txt'), os.path.join(dst_lbl, base + '.txt'))核心逻辑是先用文件名里的前缀把图片分组,再按组划分,而不是按单张图片划分。这样同一个现场的照片不会同时出现在训练集和验证集里,评估结果更接近真实场景。random.seed(42)固定随机数,保证别人复现你时划分结果一致。后缀过滤记得带上大写,很多相机导出的图片后缀就是.JPG。
3.3 训练一个YOLO检测模型:一个能跑通的配置
数据准备好后,训练配置的yaml文件是第一个要写的东西。里面指定数据集路径、类别数量和类别名,建议所有路径都用相对路径,方便换机器重跑。
# crop_disease.yaml path: ../datasets/crop_insect train: images/train val: images/val nc: 3 names: ['rice_blast', 'rice_brown_spot', 'rice_leaf_roller']path是数据集根目录,train和val只写到图片文件夹,框架会自动到同级的labels目录找文本标签。nc是类别数量,names里的顺序必须和生成标签时的class_names完全一致,比如转换脚本里class_names是['rice_blast', 'rice_brown_spot', 'rice_leaf_roller'],这里也必须是这个顺序。这个顺序错一位,模型训练的类别映射就全乱了。
训练命令用下面这个模板:
python train.py --img 640 --batch 16 --epochs 100 --data crop_disease.yaml --weights pretrained.pt --name disease_run1img 640表示训练时把图片缩放到640x640,病斑较小的图片可以试着用960,但显存占用会涨一倍。batch 16在8G显存的GPU上已经很紧,显存只有6G的话降到8或4。epochs 100看起来多,但配上早停机制,通常五六十轮就到平台期了。weights指定预训练权重,建议拿在COCO上训过的通用权重,别用随机初始化,病虫害数据量通常不够从零开始训练。
3.4 从训练日志到可视化:如何判断模型真正能用了
训练过程中,每轮会输出box_loss、cls_loss、mAP等指标。很多新手只盯着loss,看它在降就放心,其实loss下降不一定代表检测效果好。更可靠的信号是验证集上的mAP@0.5,这个值指IoU阈值取0.5时的平均精度,做到0.8以上基本能说明模型找到了病斑。如果训练集mAP很高、验证集mAP很低,那就是过拟合,这时候要回头查数据划分和增强参数。
训练结束后,用best.pt做推理验证,并把结果画出来看:
import torch model = torch.hub.load('ultralytics/yolov5', 'custom', path='runs/train/disease_run1/weights/best.pt', force_reload=True) model.conf = 0.25 model.iou = 0.45 img = 'datasets/crop_insect/images/val/test_001.jpg' results = model(img) results.save(save_dir='outputs') print(results.pandas().xyxy[0])torch.hub.load加载本地模型文件,force_reload=True是为了规避缓存旧权重。model.conf控制置信度阈值,低于0.25的框会被丢弃,田间小病斑容易漏掉,可以调到0.15看效果。model.iou是NMS的IoU阈值,框重叠严重时降低这个值,框太碎就升高。results.save会生成带检测框的图片,print出来的表格里有每个框的坐标、类别和置信度,这是排查漏检和误检的第一手材料。
4. 避坑:数据标注、类别不平衡和过拟合的血泪经验
4.1 标注框边缘不贴合,小病斑漏检到怀疑人生
现象:训练完的模型只认大片病斑,图片里那些很小的斑点全被当成背景,mAP@0.5始终在0.5以下。
原因:标注的时候为了省时间,把病斑区域框得很大,边缘包进大量健康叶组织。卷积神经网络学到的是“框内所有纹理都是病斑”,推理时碰到真正的小病斑,因为缺少那些健康纹理,置信度就被压下去。
解决:标注时把图片放大到能看清病斑边界,用LabelImg的自动保存,宁可多花时间也要让每个框贴着病斑边缘。对特别小的目标,YOLO默认的anchor可能覆盖不到,可以在训练yaml里加一句自定义anchor,或者开启多尺度训练。标注完成后抽二十张图出来目检,看框和病斑边缘的贴合程度,这一步比调十个参数都值。
4.2 类别样本悬殊,模型只会预测“大概率类”
现象:训练损失曲线很漂亮,但验证结果里稀有病斑几乎全漏,甚至所有图片都被预测成数量最多的那个类别。
原因:水稻稻瘟病300张,稻曲病只有20张,损失函数被大类主导。模型发现只要把所有目标都预测成稻瘟病,整体损失就能压得很低,小类别根本没机会学出有效特征。
解决:先做类别重采样,让每个类别在每个epoch里出现的次数接近。最简单的实现是给每张图片按类别设置采样权重,每个epoch按权重抽取训练集。还可以在损失函数上加类别权重,YOLO的配置里cls_pw就是干这个的。最稳的办法是扩充小类别的数据,把病斑区域裁剪出来做旋转、翻转、亮度变化,再放回数据集。注意别做整图复制粘贴,模型会学习到复制痕迹,而不是病斑本身。
4.3 训练集上很完美,验证集上稀碎
现象:训练集mAP一路涨到0.97,验证集mAP只有0.6,两者差距大得离谱。
原因:这是典型的场景泄漏加过拟合。同一株植物连拍几十张照片,随机分到训练集和验证集后,模型在训练时已经见过这片叶子的背景纹理,所以验证时靠背景就能蒙对。真到一片新田里,背景完全不一样,精度立刻崩掉。
解决:数据划分时按场景分组,同一个现场拍摄的照片进同一侧。比如文件名带field01前缀的照片,全部放进训练集或全部放进验证集,不交叉。同时把数据增强参数拉大,YOLO里可以调整hsv_h、hsv_s、hsv_v、degrees、translate、scale这些参数,增强相当于免费的模型正则化,能缓解过拟合。增强不是越大越好,旋转超过45度后病斑形态就失真了,要控制在合理范围。
4.4 标签类别名错位,模型学到混乱映射
现象:训练能正常启动,但每个epoch的cls_loss都是负数,或者验证集上precision突然变0。
原因:data yaml里的names顺序和标注txt里的类别id对不上。比如标签文件里0代表稻瘟病,yaml里names[0]却写成了稻曲病,模型学的映射从一开始就是错的,指标自然全乱。
解决:在训练前写个校验脚本,随机读几个txt,打印每一行的第一个数字,然后和yaml里的names序号对比。正规做法是先维护一个classes.txt固定顺序,所有转换脚本和训练yaml都从它读取类别名,保证程序全链路一致。这个脚本花五分钟写,能省掉后面一整天的排错时间。
4.5 换台电脑复现结果,mAP波动超过5个点
现象:同一份代码、同一个best.pt,在自己电脑和实验室电脑上跑验证,mAP差了5个点以上。
原因:检测模型推理结果受CUDA、PyTorch版本影响,不同版本实现的算子略有差异。再加上NMS阈值、置信度阈值如果没写死,而是依赖配置文件默认值,换机器后结果就跑偏。
解决:训练和推理的环境都能用requirements.txt锁死版本,必要时把显卡驱动和CUDA版本记在README里。更保险的做法是把模型推理参数在代码里显式写上,比如model.conf = 0.3、model.iou = 0.45,不要在推理时临时从外部读取。这样换了机器,至少验证结果对得上,不会被质疑实验不严谨。
5. 从模型到毕设系统:用PyQt或本地Web端把检测结果交出去
5.1 封装推理函数:别在界面里重复加载模型
很多毕设代码把模型加载写在界面控件的响应函数里,每点一次按钮就加载一次模型,用起来卡得没法看。正确做法是把模型加载提到顶层,只加载一次,后续所有推理调用同一个模型实例。
import torch _model = None def get_model(): global _model if _model is None: _model = torch.hub.load('ultralytics/yolov5', 'custom', path='runs/train/disease_run1/weights/best.pt') _model.conf = 0.3 _model.iou = 0.45 return _model def predict(image_path): model = get_model() results = model(image_path) detections = [] for _, row in results.pandas().xyxy[0].iterrows(): detections.append({ 'name': row['name'], 'confidence': float(row['confidence']), 'bbox': [int(row['xmin']), int(row['ymin']), int(row['xmax']), int(row['ymax'])] }) return detectionsget_model里用了全局变量_model做缓存,第一次调用时加载权重,之后直接返回已有实例。predict负责把模型输出整理成Python字典列表,里面的name、confidence、bbox都是前端展示要用的字段。bbox坐标统一转成整数,避免后续在PyQt或Web端画线时碰到float类型报错。
5.2 用Flask搭一个本地识别接口:浏览器也能跑
如果你的毕设要求“有界面”,Flask是最轻量的方案。老师直接在浏览器里访问一个地址,不用装PyQt、不用打包exe。下面是一个完整的本地识别接口:
from flask import Flask, request, jsonify import tempfile import os from predict import predict app = Flask(__name__) @app.route('/detect', methods=['POST']) def detect(): file = request.files['image'] suffix = os.path.splitext(file.filename)[1] with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: file.save(tmp.name) tmp_path = tmp.name result = predict(tmp_path) os.remove(tmp_path) return jsonify({'detections': result}) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000)接口接收POST上传的图片,先保存到临时文件,再调用predict得到结果,返回JSON。tempfile是避免多个用户同时上传时文件名冲突。用命令行测接口是最快的验证方式:
curl -X POST -F "image=@test.jpg" http://127.0.0.1:5000/detect如果返回的JSON里能看到detections列表,整个流程就已经通了。想扩展成批量识别,就把循环放在predict外面,每次传入一张图片路径。
5.3 把检测框画到图上:用PIL而不是matplotlib
界面端要展示检测框,很多人习惯用matplotlib,但它在PyQt或Flask场景里既慢又容易阻塞线程。PIL的ImageDraw更轻,几行代码就够:
from PIL import Image, ImageDraw def draw_boxes(image_path, detections, output_path): img = Image.open(image_path).convert('RGB') draw = ImageDraw.Draw(img) for det in detections: x1, y1, x2, y2 = det['bbox'] draw.rectangle([x1, y1, x2, y2], outline=(255, 0, 0), width=3) draw.text((x1, y1 - 12), det['name'], fill=(255, 0, 0)) img.save(output_path) return output_pathdraw.rectangle的width控制框的粗细,低分辨率图片建议用2到4像素。draw.text在默认字体下遇到中文会显示成方块,所以类别名最好先用英文或拼音,或者显式加载一个中文字体文件。如果前端要显示图片,可以直接把绘制后的图片转成base64字符串返回,省去文件传输这一步。
5.4 想要桌面端?PyQt的调用方式
如果老师指定要桌面程序,PyQt5是常见选择。核心思路和Flask一致:界面线程只负责选择图片和展示结果,推理永远在后台函数里完成。
from PyQt5.QtWidgets import QApplication, QLabel, QPushButton, QFileDialog, QVBoxLayout, QWidget from predict import predict class MainWindow(QWidget): def __init__(self): super().__init__() self.label = QLabel('选择一张图片') self.btn = QPushButton('打开图片') self.btn.clicked.connect(self.open_image) layout = QVBoxLayout() layout.addWidget(self.label) layout.addWidget(self.btn) self.setLayout(layout) def open_image(self): path, _ = QFileDialog.getOpenFileName(self, '选择图片', '', 'Images (*.jpg *.png)') if path: detections = predict(path) print(detections)这个示例只展示了按钮和标签的基本结构。实际项目里,你可以在open_image里调用predict后,把返回的检测框坐标画到QLabel里的QPixmap上。重点还是先有predict这个干净接口,再谈界面,顺序反了代码会越写越乱。
6. 把准确率再往上推:迁移学习、错误分析和参数微调
迁移学习是检测项目里最有效的提分手段。很多人拿到模型就从头随机初始化训练,几百张病虫害图片根本学不动。正确做法是加载一份预训练权重,把骨干网络浅层冻结,只让检测头和后几层卷积跟着自己的数据集更新。Ultralytics框架里直接在训练命令加--freeze就行:
python train.py --img 640 --batch 16 --epochs 50 --data crop_disease.yaml --weights pretrained.pt --freeze 10freeze 10代表冻结前10层卷积,这些层学的是边缘、颜色这类通用特征,对病虫害同样有效,没必要重新训。如果你的数据集只有两三百张,建议freeze开到15以上;数据量上千张后可以只冻结5层或者不冻结,效果反而更好。
提分之外,答辩时最容易被问的是“你这系统的短板在哪”。与其说空话,不如做一份错误分析。把验证集所有图片跑一遍推理,把置信度低和类别判错的图单独抽出来看。我自己在做水稻病害检测时发现,所有误检都集中在反光水珠和阴影上,它们和病斑在RGB图里确实很像。针对这个问题,我在预处理里加了去高光步骤,误检率直接降了一半。这种分析比再调一轮参数更能说服人。
最后记得调NMS参数。训练完的mAP看着可以,但实际推理时框叠成一团,多半是IoU阈值太松。在验证集上画出来看,框叠得多就把iou从0.45降到0.3,框太稀疏就升到0.6。没有统一答案,按你自己的目标尺寸调一轮就行。做这个方向最深的教训是:把时间花在数据清洗和错误分析上,比反复改网络结构收益更高。希望这些踩坑经验能帮你少走弯路,早点跑出一个自己满意的检测系统。
本文还有配套的精品资源,点击获取