☰
基于机器学习与SVM的农作物病虫害识别系统实战
2026/10/2 4:09:13 网站建设 项目流程

简介:基于机器学习实现的农作物病虫害识别系统,是一份由导师指导并认可的高分毕业设计项目,面向计算机相关专业正在准备毕设的学生,以及需要项目实战练习的学习者,也可用作课程设计或期末大作业。资源共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], levels=256, symmetric=True, normed=True) 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 和 SVM,scikit-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", X=X, y=y) 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_size=0.2, stratify=y, random_state=42 ) scaler = StandardScaler().fit(X_train) X_train_scaled = scaler.transform(X_train) X_test_scaled = scaler.transform(X_test) pca = PCA(n_components=0.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(kernel="rbf", class_weight="balanced", probability=True) grid = GridSearchCV(svm, param_grid, cv=5, scoring="f1_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_size=0.2加stratify=y的组合是我的习惯做法:随机划分但保持测试集各类别比例与原始数据集一致,防止某类病害因为随机抽样的偶然性在测试集中变成了 0 张。random_state=42固定下来,保证每次运行划分结果相同,这是答辩时被问到“结果能不能复现”时的后悔药。

PCA 设置为n_components=0.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_k=3): 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]取出第一张图片的结果。probability=True在训练时已经开启,SVM 会额外做一次 Platt 缩放把决策值转成概率分布,虽然严格来说这不是真正的概率,但在演示场景中用来判断“模型对自己结果有多确信”是够用的。

top_k=3的设置是有意为之的:只显示概率最高的那个类别容易让用户盲目信任输出,把前三名都列出来反而能暴露相似病害之间的歧义,这在农业场景里对使用者其实是更有价值的信息。

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_k=1) 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(suffix=suffix) 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(host="0.0.0.0", port=5000, debug=False)

tempfile.mkstemp会生成一个唯一的临时文件路径,用完后立刻删除,避免服务器磁盘被测试图片占满。debug=False在演示时要关掉,调试模式下 Flask 会启用 reloader,导致模型文件被加载两次,占用双倍内存。

三个跨平台部署方案里,Flask 接口是最省事的一种,前端不管是微信小程序、网页还是 Postman 都能直接调。系统演示到这一步,已经从“跑得通”升级到了“拿得出手”,你在答辩现场只需要准备一台笔记本和一台手机,打开浏览器上传一张叶片照片就足够说明问题了。

5. 避坑指南:五个最容易让模型翻车的问题排查

5.1 模型把大多数图片都判成样本最多的那个类

现象:训练完成后测试集总准确率有 90%,但看分类报告发现某一类召回率接近 100%,其他类只有 60% 甚至更低,模型输出几乎被少数几个大类别垄断。

原因:数据集类别不平衡。比如健康叶片有 1500 张,早疫病只有 120 张,SVM 在不加干预时会倾向于把边界附近的样本划到样本量更大的那一侧,因为这样做整体错误率最低,但对我们识别早期病害没有意义。

解决:在 SVC 中设置class_weight="balanced",让算法根据每个类别样本量的倒数自动调整误分类惩罚权重。同时把评估指标从 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%,最后兜住我的正是那份提前锁定的、来源不同且没参与任何调参的验证数据。希望这套流程能帮你在做毕设时少走一段弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询