
简介基于机器学习实现的农作物病虫害识别系统是一份由导师指导并认可的高分毕业设计项目面向计算机相关专业正在准备毕设的学生以及需要项目实战练习的学习者也可用作课程设计或期末大作业。资源共477个文件包含全部项目源码与配套数据集压缩包约82MB其中包含150个GIF演示动图、147个HTML页面以及模型权重pth、SQLite数据库、CSS/JS前端样式与脚本、TXT说明文档等可支撑从模型训练到结果展示的完整流程。目前已有1264人学习下载项目经过严格调试可直接运行并在此基础上进行二次开发。配套数据集与文档能帮助读者快速理解农作物病虫害识别的实现思路无论是完成毕设答辩、课程展示还是扩展新的识别功能都具有实用参考价值。1. 农作物病虫害识别系统毕业设计选它图的就是“数据可解释、流程可答辩”每年毕设季都能看到大量识别类题目而基于机器学习实现的农作物病虫害识别系统是其中性价比极高的一类。很多人第一反应是上深度学习但实际上拿到一个“源码数据集”的完整工程后你会发现这套经典机器学习方案在 CPU 上就能完成训练和推理几百张图片就能跑出可用的效果而且每个环节都能在答辩时讲清楚原理。它解决的是这样一件事给定一张作物叶片照片系统告诉你它是健康的还是感染了某种病害并给出置信度。本文不假设你有 GPU也不要求你背熟所有公式只讲如何把数据集、特征提取、分类器训练和推理部署串成一条能跑的流水线适合想用低门槛方式做出效果并把它讲明白的本科毕设和课程设计。2. 从图像到病害标签选型与数据组织是成败前提2.1 为什么这里选 SVM 而不是一上来就上 CNN农作物病虫害识别本质上是图像分类任务。看到“图像分类”四个字现在很多人会直接联想到卷积神经网络但在这个毕业设计场景下经典机器学习路线往往更稳。原因有三点。第一是样本量。CNN 在几千张图的小数据集上很容易过拟合虽然可以用 ImageNet 预训练权重做迁移学习但微调过程涉及学习率、冻结层数、数据增强策略等一堆额外变量调参成本明显更高。第二是算力。SVM 训练在 CPU 上几十秒到几分钟就结束CNN 哪怕用预训练模型也要跑几十个 epoch笔记本没有独显会非常难受。第三是可解释性。答辩时老师问“你这模型为什么这么判”SVM 可以讲间隔最大化、支持向量配合特征工程还能讲清楚用的颜色、纹理和形状里的哪些信息CNN 你只能讲卷积核自动提取特征一旦追问就很容易露怯。什么时候才应该换 CNN当你的数据集超过一万张、病害形态差异很细微、或者做的是细粒度识别时人工特征会碰到天花板那时再考虑 ResNet、MobileNet 这类轻量网络也不迟。本系统以 SVM 为主线是“数据量有限、CPU 环境、需要把原理讲透”这三个约束下的最优解。2.2 数据集目录组织写代码前先定规矩拿到数据集后的第一件事不是写加载脚本而是把磁盘上的目录结构定好。常见的做法是每个类别建一个文件夹文件夹名用英文或拼音不要用中文。中文路径在 Windows 下配合 OpenCV 读取经常出现编码问题这个坑我踩过不止一次。dataset/ ├── tomato_healthy/ │ ├── 001.jpg │ ├── 002.jpg │ └── ... ├── tomato_leaf_mold/ │ ├── 001.jpg │ └── ... ├── tomato_bacterial_spot/ │ └── ... └── ...这样组织只要用os.listdir()就能同时拿到图片路径和标签标签就是目录名。建议每类图片数量不要低于 100 张类别数控制在 5 到 15 个之间覆盖常见病即可。如果某类图片太少可以做最基础的数据增强左右翻转、旋转 10 度、亮度抖动。增强操作要在特征提取之前完成生成的是增强后的图片文件而不是在内存里处理这样特征提取脚本可以保持简单。2.3 特征工程三板斧颜色矩、GLCM 与 Hu 矩经典机器学习做图像分类核心是特征工程。农作物的病害识别里病斑通常伴随三类视觉变化叶面颜色改变、纹理结构改变、病斑形状改变。所以常见做法是同时提取颜色、纹理、形状三类特征拼成一个向量喂给分类器。颜色特征用颜色矩计算每个通道的均值、标准差和偏度能粗略描述颜色分布。纹理特征用灰度共生矩阵在 0、45、90、135 四个方向上计算对比度、相关性、能量、同质性能描述叶片表面的粗糙程度和重复模式。形状特征用 Hu 矩的 7 个值描述病斑区域的轮廓几何特征。这里有一个容易出错的小地方颜色通道不只算 RGB还会把图像转成 HSV 再算一遍。因为 HSV 空间中的 H 和 S 分量对光照的敏感度比 RGB 更低病害引起的颜色偏移在 HSV 下表现得更有区分度。如果只用 RGB 三个通道特征向量的表达能力会弱一截。特征提取脚本是整套系统的地基下面给出一个可直接运行的完整版本import os import cv2 import numpy as np from skimage.feature import graycomatrix, graycoprops def calc_color_moments(image): 计算 BGR 和 HSV 两个色彩空间共 6 个通道的颜色矩 moments [] hsv cv2.cvtColor(image, cv2.COLOR_BGR2HSV) for channel in cv2.split(image): # B, G, R mean channel.mean() std channel.std() skew np.sign(((channel - mean) ** 3).mean()) * \ (np.abs(((channel - mean) ** 3).mean()) ** (1.0 / 3)) moments.extend([mean, std, skew]) for channel in cv2.split(hsv): # H, S, V mean channel.mean() std channel.std() skew np.sign(((channel - mean) ** 3).mean()) * \ (np.abs(((channel - mean) ** 3).mean()) ** (1.0 / 3)) moments.extend([mean, std, skew]) return moments def calc_glcm_features(gray): 四方向灰度共生矩阵对比度、相关性、能量、同质性 feats [] for angle in [0, np.pi / 4, np.pi / 2, 3 * np.pi / 4]: glcm graycomatrix(gray, distances[1], angles[angle], levels256, symmetricTrue, normedTrue) for prop in [contrast, correlation, energy, homogeneity]: feats.append(graycoprops(glcm, prop)[0, 0]) return feats def extract_features(img_path, size(256, 256)): image cv2.imread(img_path) if image is None: return None image cv2.resize(image, size) gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) features [] features.extend(calc_color_moments(image)) features.extend(calc_glcm_features(gray)) moments cv2.HuMoments(cv2.moments(gray)).flatten() features.extend(np.sign(moments) * np.log10(np.abs(moments) 1e-10)) return np.array(features)这段代码有几个细节值得注意。颜色矩的偏度计算不能直接用((channel - mean) ** 3).mean() ** (1/3)因为负数的三次方开三次方根在 numpy 里容易产生 NaN所以我用np.sign配合np.abs拆开算。Hu 矩的值范围跨度极大从 1e-2 到 1e-10 都有直接拼进特征向量会让数值大的维度主导分类器所以要取一次对数压缩量级。每个通道的颜色矩 3 个值 × 6 个通道等于 18 维GLCM 是 4 个方向 × 4 个统计量等于 16 维Hu 矩 7 维总共 41 维特征。这个维度对于 SVM 来说是相当友好的不需要太多样本就能学到一个稳定的决策边界这也是经典路线对小数据集友好的一个结构性原因。3. 用 Python 跑通训练全流程特征提取到模型落地3.1 环境依赖装什么、怎么装不翻车这套方案的核心依赖是 OpenCV、scikit-learn、scikit-image 和 joblib。OpenCV 负责图像读写和颜色空间转换scikit-learn 提供标准化、PCA 和 SVMscikit-image 用来计算 GLCM 纹理特征joblib 负责模型持久化。安装命令如下pip install opencv-python scikit-learn scikit-image joblib pandas如果你用的是 conda 管理 Python 环境建议新开一个干净环境再装避免和已有的 numpy 版本冲突。这里特别提一句“python 安装教程”最常被忽略的部分确认pip指向的是当前 Python 解释器的包目录尤其电脑里同时装有 Python 3.8 和 3.11 时很容易出现某一边能 import 另一边不能 import 的诡异问题。用python -m pip install代替直接pip install能避开百分之八十的环境错乱问题。3.2 批量提取特征并保存让脚本只跑一次训练前需要把数据集中所有图片都转成特征向量。这一步建议把中间结果保存成.npz文件后续训练脚本直接加载不用每次调参都重新跑一遍特征提取。import os import numpy as np from extract_features import extract_features ROOT dataset X, y [], [] for label in sorted(os.listdir(ROOT)): label_dir os.path.join(ROOT, label) if not os.path.isdir(label_dir): continue for fname in os.listdir(label_dir): if not fname.lower().endswith((.jpg, .jpeg, .png)): continue path os.path.join(label_dir, fname) feats extract_features(path) if feats is not None: X.append(feats) y.append(label) print(f[OK] {label}: {len([v for v in y if v label])} images) X np.array(X) y np.array(y) np.savez(features.npz, XX, yy) print(feature matrix shape:, X.shape)这段脚本的逻辑是遍历每个类别目录逐张提取特征最后把所有特征矩阵和标签数组存到一个.npz文件里。extract_features返回None时说明图片读取失败直接跳过避免因为个别坏图导致整个训练崩溃。注意sorted(os.listdir(ROOT))这一步目录名排序后标签的编码顺序是固定的后续类别名称和数字标签的映射关系就不会乱。如果漏掉排序在不同机器上跑出的标签编码可能不一致模型文件换台机器就用不了。3.3 训练 SVM标准化、PCA 与网格搜索特征提取完成之后进入训练阶段。这一步的核心操作是先用 StandardScaler 把每个维度缩放到均值为 0、方差为 1再用 PCA 降维去除冗余信息最后用 RBF 核 SVM 做分类。三个步骤缺一不可下面是完整的训练脚本import numpy as np import joblib from sklearn.preprocessing import StandardScaler from sklearn.decomposition import PCA from sklearn.model_selection import train_test_split, GridSearchCV from sklearn.svm import SVC from sklearn.metrics import classification_report data np.load(features.npz) X, y data[X], data[y] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, stratifyy, random_state42 ) scaler StandardScaler().fit(X_train) X_train_scaled scaler.transform(X_train) X_test_scaled scaler.transform(X_test) pca PCA(n_components0.95).fit(X_train_scaled) X_train_pca pca.transform(X_train_scaled) X_test_pca pca.transform(X_test_scaled) param_grid { C: [0.1, 1, 10, 100], gamma: [scale, 0.01, 0.001], } svm SVC(kernelrbf, class_weightbalanced, probabilityTrue) grid GridSearchCV(svm, param_grid, cv5, scoringf1_macro, n_jobs-1) grid.fit(X_train_pca, y_train) print(best params:, grid.best_params_) print(best cv score:, grid.best_score_) y_pred grid.predict(X_test_pca) print(classification_report(y_test, y_pred)) joblib.dump({ scaler: scaler, pca: pca, model: grid.best_estimator_, classes: grid.classes_, }, plant_disease_svm.joblib) print(model saved to plant_disease_svm.joblib)这段代码中最关键的是先.fit(X_train)再.transform(X_test)的顺序。标准化和 PCA 都只能从训练集学习参数测试集数据要用训练集学到的参数做同样的变换否则会引入数据泄漏得到虚高的评估分数。test_size0.2加stratifyy的组合是我的习惯做法随机划分但保持测试集各类别比例与原始数据集一致防止某类病害因为随机抽样的偶然性在测试集中变成了 0 张。random_state42固定下来保证每次运行划分结果相同这是答辩时被问到“结果能不能复现”时的后悔药。PCA 设置为n_components0.95表示保留 95% 的方差信息。41 维特征通常降下来只剩 15 到 20 维去除了颜色三个通道之间高相关带来的冗余。网格搜索的C和gamma是 RBF 核 SVM 的两个核心参数C控制误分类的惩罚力度太大容易过拟合太小容易欠拟合gamma控制单个样本的影响半径值越大决策边界越复杂。用 5 折交叉验证和f1_macro评分即使各类样本数不均衡也能得到相对公平的参数选择结果。4. 从模型到可用系统推理脚本、摄像头与 Web 接口4.1 单张图片推理加载模型并输出病害名和置信度训练完成后下一步是把模型封装成可以被外部调用的推理脚本。加载模型文件时要把标准化器、PCA 和 SVM 分类器三者同时取出来三条变换缺一不可因为训练时输入到 SVM 的已经是标准化和降维之后的数据向量。import cv2 import numpy as np import joblib from extract_features import extract_features bundle joblib.load(plant_disease_svm.joblib) scaler bundle[scaler] pca bundle[pca] model bundle[model] classes bundle[classes] def predict_image(img_path, top_k3): feats extract_features(img_path) if feats is None: return None feats feats.reshape(1, -1) feats_scaled scaler.transform(feats) feats_pca pca.transform(feats_scaled) proba model.predict_proba(feats_pca)[0] top_idx np.argsort(proba)[::-1][:top_k] result [] for idx in top_idx: result.append({ disease: classes[idx], confidence: float(proba[idx]), }) return result if __name__ __main__: res predict_image(test_leaf.jpg) for item in res: print(f{item[disease]}: {item[confidence]:.2%})model.predict_proba返回的是一个二维数组每一行对应一张图片的各类别概率这里取[0]取出第一张图片的结果。probabilityTrue在训练时已经开启SVM 会额外做一次 Platt 缩放把决策值转成概率分布虽然严格来说这不是真正的概率但在演示场景中用来判断“模型对自己结果有多确信”是够用的。top_k3的设置是有意为之的只显示概率最高的那个类别容易让用户盲目信任输出把前三名都列出来反而能暴露相似病害之间的歧义这在农业场景里对使用者其实是更有价值的信息。4.2 摄像头实时识别抽帧比每帧处理更实用毕设演示时经常会有人提出“能不能打开摄像头直接识别”这个需求的实现并不复杂但要注意性能与流畅度的平衡。SVM 的推理本身很快一张图从读取到预测大约几十毫秒但瓶颈在特征提取里的 GLCM 计算上。每帧都跑一遍会拖累画面流畅度常见做法是视频流持续显示但只每 0.3 秒抽一帧做识别。import cv2, time import numpy as np from predict import predict_image cap cv2.VideoCapture(0) if not cap.isOpened(): print(cannot open camera) exit(1) last_time 0 result_text while True: ret, frame cap.read() if not ret: break now time.time() if now - last_time 0.3: last_time now rgb_frame cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) cv2.imwrite(_frame_tmp.jpg, rgb_frame) result predict_image(_frame_tmp.jpg, top_k1) result_text result[0][disease] if result else unknown cv2.putText(frame, result_text, (30, 30), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 255, 0), 2) cv2.imshow(plant disease detection, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这里我先把帧转成 RGB 再临时存盘predict_image内部用的是cv2.imread读取文件这样推理函数的输入输出完全复用前面的代码。每 0.3 秒抽一帧对演示来说是足够的节奏既不会让画面卡顿也不会因为识别结果刷新太慢显得系统迟钝。4.3 用 Flask 包一层 HTTP 接口演示不限场地摄像头方案适合现场演示但如果你想在答辩时用手机给老师演示或者想把这个系统包成一个小服务用 Flask 包一个 POST 接口是最快的路径。接口接收一张图片文件返回 JSON 格式的预测结果。import os import tempfile from flask import Flask, request, jsonify from predict import predict_image app Flask(__name__) app.route(/predict, methods[POST]) def predict(): if image not in request.files: return jsonify({error: no image uploaded}), 400 file request.files[image] suffix os.path.splitext(file.filename)[1] fd, tmp_path tempfile.mkstemp(suffixsuffix) with os.fdopen(fd, wb) as f: f.write(file.read()) result predict_image(tmp_path) os.remove(tmp_path) if result is None: return jsonify({error: failed to read image}), 422 return jsonify({predictions: result}) if __name__ __main__: app.run(host0.0.0.0, port5000, debugFalse)tempfile.mkstemp会生成一个唯一的临时文件路径用完后立刻删除避免服务器磁盘被测试图片占满。debugFalse在演示时要关掉调试模式下 Flask 会启用 reloader导致模型文件被加载两次占用双倍内存。三个跨平台部署方案里Flask 接口是最省事的一种前端不管是微信小程序、网页还是 Postman 都能直接调。系统演示到这一步已经从“跑得通”升级到了“拿得出手”你在答辩现场只需要准备一台笔记本和一台手机打开浏览器上传一张叶片照片就足够说明问题了。5. 避坑指南五个最容易让模型翻车的问题排查5.1 模型把大多数图片都判成样本最多的那个类现象训练完成后测试集总准确率有 90%但看分类报告发现某一类召回率接近 100%其他类只有 60% 甚至更低模型输出几乎被少数几个大类别垄断。原因数据集类别不平衡。比如健康叶片有 1500 张早疫病只有 120 张SVM 在不加干预时会倾向于把边界附近的样本划到样本量更大的那一侧因为这样做整体错误率最低但对我们识别早期病害没有意义。解决在 SVC 中设置class_weightbalanced让算法根据每个类别样本量的倒数自动调整误分类惩罚权重。同时把评估指标从 accuracy 切换成f1_macro或各类别的召回率。如果类别不平衡真的很严重也可以对少数类做过采样最简单的方式是重复抽样但注意重复次数别超过 3 倍否则容易过拟合到重复样本上。5.2 训练集准确率 99%测试集却只有 70%现象训练阶段指标一路走高测试集评估却明显回落甚至交叉验证也正常但最终测试不正常。原因最常见的是数据泄漏。很多人在做特征工程时先对全部数据做标准化或 PCA再随机划分训练集测试集这样测试集的信息已经“混入”了标准化器的均值方差里等于模型提前偷看了答案。解决所有变换类操作都严格遵守先 fit 训练集再 transform 测试集的原则。检查一下训练代码里 StandardScaler 和 PCA 是否都只用了X_train做 fit。另外检查数据增强是否把同一张图的增强版本同时放进了训练集和测试集这种情况要用按图片组划分的方式避免。5.3 同一病害在不同光照环境下被分到不同类别现象室内灯光下拍的照片识别正确到了室外自然光下同样的病被识别成另一种病甚至被识别为健康。模型的颜色特征分布发生了明显偏移。原因颜色矩特征对光照强度非常敏感RGB 三个通道的均值和标准差会随环境亮度大幅变化偏度值相对稳定但也并非完全不受影响。这类问题在大田场景里格外常见因为露水反光、逆光拍摄、遮荫都会显著改变叶片颜色。解决一个有效的缓解思路是在特征提取中对图片先做光照归一化常见做法是把 HSV 空间里的 V 通道做直方图均衡化再用均衡化之后的图提取颜色特征。另一个思路是提高纹理特征在向量中的占比GLCM 特征描述的是灰度分布的空间关系对绝对亮度不那么敏感。如果数据多样性允许更推荐在生产环境里额外采集一批逆光和遮荫的照片加入训练集让 SVM 见过更多光照条件。5.4 转灰度图后颜色特征全废了现象复现别人的代码时发现特征向量长度对不上或者模型判什么都靠纹理硬撑效果明显不如预期。原因这是特征提取代码里的隐藏问题。如果你在某个环节用cv2.imread(img_path, cv2.IMREAD_GRAYSCALE)读了图后续不管转不转回 RGB原始颜色信息都已经丢了。颜色矩的计算对象从 BGR 三个通道变成了单个灰度通道特征向量长度从 18 维缩水到 9 维以下。解决检查imread是否使用了默认参数不要指定灰度模式。如果发现灰度图片已经保存到了数据集里只能重新从原始彩色图片走一遍特征提取流程这一步没有偷懒的捷径因为灰度图保留不了颜色信息。5.5 模型文件换台电脑加载直接报错现象把plant_disease_svm.joblib从自己电脑拷到另外一台电脑加载时抛出异常要么提示模块不存在要么提示对象反序列化失败。原因joblib 序列化时会把 sklearn 的类路径写进文件里目标机器的 scikit-learn 版本如果和训练时不匹配类库的路径或参数签名发生了变化反序列化就会失败。这也是 pickle 在机器学习项目里最经常踩的坑。解决模型文件名里带上 sklearn 版本号和特征提取代码的版本号比如plant_disease_svm_v3_sklearn1.2.joblib。同时在推理脚本里用import sklearn; sklearn.__version__打印版本号与模型训练环境核对。最稳妥的做法是训练和推理都运行在同一套虚拟环境里用requirements.txt锁住版本不要裸装最新版。6. 进阶验证从“识别对”到“识别稳”的两个做法6.1 固定一份来源完全独立的对接数据交叉验证分数再漂亮也只能说明模型在同源分布的数据上表现稳定。真正的挑战是现场拍摄的图片与训练集图片之间存在设备差异、时间差异、地域差异。我一般会在项目后期留出 50 到 80 张“从没参与过特征提取和训练步骤”的独立照片它们可以是另一种手机拍摄的也可以是另一块田里的放到最后一层做验收。import numpy as np import joblib from extract_features import extract_features bundle joblib.load(plant_disease_svm.joblib) scaler, pca, model bundle[scaler], bundle[pca], bundle[model] X_final, y_final [], [] for img_path, true_label in final_check_list: feats extract_features(img_path) feats scaler.transform(feats.reshape(1, -1)) feats pca.transform(feats) pred model.predict(feats)[0] print(f{img_path}: pred{pred}, true{true_label})这批数据不要参与网格搜参、不要用于调阈值只在最后跑一遍用它衡量系统在真实条件下的“泛化底座”。6.2 盯住每类召回率而不是总正确率农作物病虫害识别的落地场景里漏掉一个病比误判一个病的代价往往更高。我习惯给每个类别单独统计召回率同时观察混淆矩阵里哪些类别互相被搞混。早疫病和晚疫病在叶片上都是褐色斑点形态差异很小如果这两类的混淆比例持续偏高就需要回看训练样本里这两类的图片质量。另一个高性价比的小技巧是为每一类单独设定一个置信度阈值概率低于阈值的输出都标成“疑似待人工复核”这样系统虽然识别率不变但面对陌生图片时表达方式更稳妥也更符合农业使用的实际流程。这是我做识别项目最深的体会测试集分数是给评委看的独立验证集上的稳定性才是给自己用的。当年我拿着一个测试集 97% 的模型走到校外演示被现场逆光照片打成 80%最后兜住我的正是那份提前锁定的、来源不同且没参与任何调参的验证数据。希望这套流程能帮你在做毕设时少走一段弯路。本文还有配套的精品资源点击获取