简介:围绕Python与OpenCV的植物叶片识别实验资料包,面向正在学习图像处理、计算机视觉的初学者和高校学生,解决从轮廓提取到叶片特征量化分析的问题。压缩包共1个文件,为doc格式文档,大小331KB,内容以实验指导为主,配合本地图像即可边学边练。文档覆盖图像分割、轮廓查找与绘制、面积周长及矩的计算、直边界与旋转边界矩形、最小外接圆等内容,并给出关键函数调用和可运行代码示例;通过该实验可掌握cv2.findContours、cv2.drawContours、cv2.moments、cv2.contourArea、cv2.arcLength、cv2.boundingRect、cv2.minAreaRect、cv2.minEnclosingCircle等常用轮廓相关方法,理解轮廓性质与图像特征表示流程。目前已有5413人浏览学习,适合需要完成课程实验或入门图像识别的读者参考。
1. 植物叶片识别:别人跑通了我却翻车,差在哪
做植物叶片识别,很多人一上来就打算上深度学习,但真实场景里样本量往往只有几百张,训练集小、类别杂、背景乱,神经网络的收益远没有想象中高。我自己在某个模拟项目X里用 Python 和 OpenCV 走完了一整套叶片识别流程:从拍摄原图到分割、提特征、训练分类器,最终在 12 类叶片上拿到了 91% 的准确率。这个结果不算惊艳,但胜在可复现、可解释,而且单张识别只要几十毫秒。这篇笔记就是把这条路线完整拆开:预处理怎么做才稳、特征怎么提才有区分度、分类器参数怎么调、以及那些让新手反复翻车的细节到底出在哪。
2. 预处理是命门:从原图到干净叶片轮廓
2.1 颜色空间选择:为什么直接抠 RGB 绿色会翻车
叶片识别的第一步是把叶片从背景里摘出来。不少新手直接在 RGB 空间里设定绿色的阈值范围,比如 G 通道大于 100、R 和 B 小于某个值,这种做法在实验室均匀光照下能用,一换到室外就崩。原因是 RGB 三个通道高度相关,光照一变,三个通道的值一起漂移,固定阈值立刻失效。
我一般会先转成 HSV 颜色空间再做分割。HSV 把色相(H)、饱和度(S)、明度(V)分开,其中 H 通道对光照变化相对不敏感,绿色的叶片无论明暗,H 值都落在 35 到 85 这个区间附近。饱和度和明度则用来过滤掉背景里偏灰、偏暗的区域。这个思路是传统图像分割里的标准做法,也是大多数叶片分割方案的基础,你后面调的阈值基本都是围绕 HSV 三个通道来设的。
另一个可选空间是 Lab 的 a 通道,它把绿色和品红放在一条轴上,对绿色分割同样有效。但 HSV 在 OpenCV 里的 API 更直接,我先用 HSV 讲清楚流程,Lab 作为备选方案放在后面提。
2.2 基于 HSV 的分割:最小可用的阈值分割代码
下面这段代码是我在模拟项目X里最常用的分割起点。它读入一张叶片照片,转到 HSV 空间,然后用 inRange 把绿色区域抠出来,最后做一次中值滤波去掉椒盐噪声。
import cv2 import numpy as np def segment_leaf(image_path): # 读取原图 bgr = cv2.imread(image_path) if bgr is None: raise ValueError("图片读取失败,检查路径") # BGR 转 HSV hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) # 绿色阈值范围:H 35-85, S 40-255, V 40-255 lower = np.array([35, 40, 40]) upper = np.array([85, 255, 255]) mask = cv2.inRange(hsv, lower, upper) # 中值滤波去噪,核大小取 5 mask = cv2.medianBlur(mask, 5) return mask这里的关键参数是 lower 和 upper 两个数组。H 的下限设 35、上限设 85,覆盖了绝大多数绿色叶片的色相范围;S 下限 40 是为了过滤掉接近灰白的区域;V 下限 40 则是为了避免把阴影里的暗背景也算进来。中值滤波的核大小取 5,既能去掉孤立的噪点,又不会把叶脉细节抹掉。
这段代码在光线均匀的室内图上是够用的。但如果你直接用这一版去处理背光拍摄的照片,大概率会得到一块残缺的叶片 mask,下一节的处理就是围绕这个残缺展开的。
2.3 形态学去噪与轮廓筛选:从乱码到完整轮廓
inRange 出来的 mask 通常不是干净的叶片形状:边缘可能有锯齿状突起、内部有孔洞、角落里有小片背景被误判成绿色。形态学操作是解决这类问题的标准手段,开运算先腐蚀再膨胀,去掉小噪点;闭运算先膨胀再腐蚀,填平内部小洞。
# 定义结构元素,椭圆核更适合叶片这种弧形边缘 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) # 先闭运算填充孔洞,再开运算去除碎屑 mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=2) # 找连通域,过滤掉面积过小的区域 num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(mask, connectivity=8) # 提取面积最大的连通域作为叶片主体 largest_area = 0 leaf_label = 0 for i in range(1, num_labels): area = stats[i, cv2.CC_STAT_AREA] if area > largest_area: largest_area = area leaf_label = i leaf_mask = np.zeros_like(mask) leaf_mask[labels == leaf_label] = 255闭运算两次是为了把叶面上因为反光产生的小黑点彻底填平。开运算两次则是把背景里误检的绿色碎草叶去掉。结构元素用椭圆核而不是矩形核,是因为叶片边缘是弧形的,矩形核容易把边缘磨出直角。取最大连通域这一步很关键,它假设画面里最大的绿色区域就是目标叶片,这个假设在单叶拍摄场景下基本成立,但如果一张图里有好几片叶子,你就得改成按面积排序取前 N 个。
到这一步,你已经拿到了相对完整的叶片二值掩膜。这个掩膜既可以直接用来做形状分析,也可以反过来把原图里的叶片区域截出来做颜色和纹理特征提取。
3. 特征工程:让机器学习认识叶片
3.1 几何特征与 Hu 矩:形状的数字化表达
叶片之间最直观的区别是形状。柳叶细长、荷叶近圆、枫叶掌状分裂,这些差异可以用一组几何特征量化。我常用的几何特征有:面积、周长、宽高比、圆形度、凸包面积比、等效椭圆的长短轴比。
contours, _ = cv2.findContours(leaf_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cnt = max(contours, key=cv2.contourArea) # 基础几何量 area = cv2.contourArea(cnt) perimeter = cv2.arcLength(cnt, True) x, y, w, h = cv2.boundingRect(cnt) aspect_ratio = w / h # 宽高比 # 圆形度:4*pi*面积 / 周长^2,圆越接近1 circularity = 4 * np.pi * area / (perimeter * perimeter) # 凸包比:面积 / 凸包面积,反映叶片边缘的凹凸程度 hull = cv2.convexHull(cnt) hull_area = cv2.contourArea(hull) solidity = area / hull_area # Hu 矩:7个不变矩,对平移/旋转/缩放不敏感 moments = cv2.moments(cnt) hu_moments = cv2.HuMoments(moments).flatten()宽高比能区分细长叶和阔叶,圆形度能区分圆叶和裂叶,凸包比对枫叶这类深裂叶片特别有效,因为它的实际面积比凸包小不少。Hu 矩是形状识别的经典特征,七个数对平移、旋转、缩放都保持不变,所以同一品种的叶片即便拍摄角度和距离不同,Hu 矩仍然相对稳定。我通常取 log 绝对值后再用,因为 Hu 矩后几维的数值极小,直接送入分类器会拖累距离计算。
注意 findContours 在 OpenCV 4.x 里返回两个值,在 OpenCV 3.x 里返回三个值。如果你用的是旧版本,需要用_, contours, _ = cv2.findContours(...)解包,这个版本差异让不少人在环境迁移时踩了坑。
3.2 颜色与纹理特征:把叶色和叶脉信息加进来
形状特征区分不开的叶片,要靠颜色和纹理来补。比如两个品种的叶子形状几乎一样,但一个深绿、一个黄绿,颜色特征就能把它们分开。
# 用掩膜截出叶片区域的 HSV 像素 leaf_pixels = hsv[leaf_mask == 255] # 三个通道的均值和标准差 h_mean, s_mean, v_mean = np.mean(leaf_pixels, axis=0) h_std, s_std, v_std = np.std(leaf_pixels, axis=0) # 简单的颜色特征向量 color_feature = np.array([h_mean, s_mean, v_mean, h_std, s_std, v_std])六个数描述叶片的颜色分布:均值说明整体色调,标准差说明颜色均匀程度。比如病斑多的叶片 S 和 V 的标准差通常会偏大。这个特征足够应付大多数品种分类任务,如果你觉得信息量不够,可以再加 HSV 三个通道的直方图,每个通道取 16 个 bin,拼起来就是 48 维的颜色直方图特征。
纹理特征我用了两种方案,一种是灰度共生矩阵(GLCM)的对比度、能量、同质性三个量,另一种是局部二值模式(LBP)直方图。GLCM 的 OpenCV 实现比较绕,我建议直接用 scikit-image 的greycomatrix,几行代码就能拿到。LBP 则适合描述叶脉纹理,但要注意它对手写参数敏感,这里不展开调参细节,先用默认参数,等分类效果不够再回来精调。
3.3 特征向量拼接与归一化:量纲不同会毁掉距离计算
把几何特征、颜色特征、纹理特征拼成一个长向量之后,第一件要做的事是归一化。面积动辄几千、Hu 矩却是小数点后七八位,不归一化直接丢给 SVM,面积会完全主导距离计算,其他特征形同虚设。scikit-learn 里的 StandardScaler 是标准做法,它把每个特征缩放到均值 0、方差 1。
from sklearn.preprocessing import StandardScaler # 假设 features 是形状为 (样本数, 特征维度) 的数组 scaler = StandardScaler() features_scaled = scaler.fit_transform(features) # 训练集上 fit,测试集上只 transform features_test_scaled = scaler.transform(features_test)这里最容易出问题的细节是 fit 和 transform 的对象:必须在训练集上 fit,然后在测试集上 transform。如果你对全部数据一起 fit,测试集的信息就泄漏到了训练过程里,评估出来的准确率会比真实水平虚高。这个错误很隐蔽,因为它不会报错,只是结果好看得可疑。
特征维度的选择上,我推荐先做一次简单的方差分析或者用随机森林的特征重要性排序,把排在后面的十几维砍掉。叶片样本通常不多,特征维度越高越容易过拟合,保持特征数量在样本数量的十分之一以内比较稳妥。
4. 训练分类器:从 KNN 到 SVM 的落地选型
4.1 数据集划分与标签组织:目录结构就是标签
分类器训练前要先想好数据怎么组织。业界最常见的做法是每个类别一个文件夹,文件夹名就是标签。这个组织方式简单、可读,而且后续加载数据时可以少写很多代码。
import os from sklearn.model_selection import train_test_split def load_dataset(root_dir): # root_dir 下每个子文件夹是一个类别 X, y = [], [] labels = sorted(os.listdir(root_dir)) label_to_id = {label: idx for idx, label in enumerate(labels)} for label in labels: label_dir = os.path.join(root_dir, label) for img_name in os.listdir(label_dir): if not img_name.lower().endswith(('.jpg', '.png', '.jpeg')): continue img_path = os.path.join(label_dir, img_name) feat = extract_feature(img_path) # 前两章的特征提取流程 X.append(feat) y.append(label_to_id[label]) return np.array(X), np.array(y), labels划分数据集时不能只做简单的随机切分,要考虑到同一个植株或同一次拍摄的照片可能高度相似,如果它们同时出现在训练集和测试集里,评估结果会虚高。稳妥的做法是按照片来源分组,比如按拍摄批次划分,保证同一批次的照片不会横跨训练测试两个集合。样本量小的时候更要留意这一点。
4.2 SVM 训练与参数搜索:别用默认参数骗自己
在几十到几百个样本的尺度下,SVM 是性价比最高的分类器。它的泛化能力比 KNN 强,调参维度比神经网络少,而且配合 RBF 核可以对非线性可分的数据建模。KNN 在样本量小、特征维度高的时候表现波动很大,随机森林容易过拟合,SVM 折中最好。
from sklearn.svm import SVC from sklearn.model_selection import GridSearchCV, cross_val_score # RBF 核的 SVM,先设一个粗略的 C 和 gamma svm = SVC(kernel='rbf', C=10.0, gamma='scale', probability=True) # 用网格搜索找 C 和 gamma param_grid = { 'C': [1, 10, 100], 'gamma': [0.001, 0.01, 0.1] } grid = GridSearchCV(svm, param_grid, cv=5, scoring='accuracy') grid.fit(X_train, y_train) print(grid.best_params_)C 是误分类惩罚系数,C 越大模型越倾向于把所有训练样本分对,但容易过拟合;gamma 控制 RBF 核的作用半径,gamma 越大边界越复杂。这两个参数对最终准确率的影响非常大,网格搜索是最可靠的调参方式。样本量小于 100 时,5 折交叉验证会比默认的 3 折更稳定,代价是训练时间稍微长一点。
评估指标不要只看准确率。叶片类别不平衡时,比如 A 类 80 张、B 类 20 张,模型全猜 A 也有 80% 准确率。这个时候要看每类的召回率和混淆矩阵,确保 B 类不是被牺牲的对象。像上面说的这种不均衡情况,我会在 GridSearchCV 里加上class_weight='balanced',让小类别的样本获得更高的权重。
4.3 识别单张叶片的完整流程:从图片到类别名的封装
训练好模型之后,要封装一个单张图片的预测函数,方便批量调用。完整流程是:读图 → 分割 → 提特征 → 归一化 → 模型预测。
def predict_leaf(image_path, model, scaler, labels): # 1. 读图与分割(复用第二节的分割函数) mask = segment_leaf(image_path) if np.sum(mask > 0) < 500: # 前景过小,判定为无叶片 return "未检测到叶片" # 2. 提取特征(复用第三节的特征函数) feat = extract_all_features(image_path, mask) # 3. 特征归一化 feat_scaled = scaler.transform(feat.reshape(1, -1)) # 4. 预测类别与置信度 proba = model.predict_proba(feat_scaled)[0] pred_idx = np.argmax(proba) # 5. 低于阈值时拒绝识别 if proba[pred_idx] < 0.5: return "置信度过低,请重新拍摄" return labels[pred_idx]这里加了两个防御逻辑:一是叶片面积小于 500 像素时直接判定为无叶片,避免把一张空背景图硬塞给模型;二是预测概率低于 0.5 时拒绝输出类别,这在真实应用里比硬给一个结果可靠得多。多类别分类的置信度阈值一般设在 0.5 到 0.7 之间,你可以根据自己的场景调整。
5. 别急着加数据:先排查这 5 个坑
5.1 光照不均导致分割出半片叶子
现象:mask 只有叶片的一半,另一半变成了黑色。 原因:光照从一侧打过来,叶片背光面的 V 值掉到了 40 以下,被 inRange 过滤掉了。 解决:把 V 的下限从 40 降到 20。代价是背景里的暗色噪点会变多,但形态学开运算能清掉大部分。另一个办法是先用 CLAHE 做一次自适应直方图均衡化,再把 V 下限放宽到 25。
5.2 叶片边缘枯黄被当成背景丢掉
现象:叶片边缘有一圈焦边,这圈焦边不在绿色 HSV 范围内,导致分割出来的轮廓比实际叶片小一圈。 原因:H 值超过 85 进入黄色区间,S 值也可能偏低。 解决:把 H 的上限从 85 提到 100,但这会把黄色背景也引进来。更稳的做法是分割完成后做个膨胀操作,让轮廓向外扩几个像素,再用边缘检测修正边界。简单场景下直接设kernel = np.ones((5,5), np.uint8); mask = cv2.dilate(mask, kernel, iterations=1)就够用了。
5.3 背景里的杂草叶片被当成目标
现象:最大连通域不是目标叶片,而是背景里的一丛杂草。 原因:拍摄时背景里有其他绿色植物,且面积比目标大。 解决:这类问题是分割方案的死角,纯靠面积排序解决不了。我一般会在采集阶段就控制背景,用纯色底板拍摄。如果必须复杂背景,最可靠的办法是加深度信息或者深度学习语义分割模型,这个超出传统 OpenCV 的范畴了,只能更换技术方案。
5.4 特征量纲差异巨大,模型学了个寂寞
现象:量化特征前后模型的准确率从 70% 掉到 40%。 原因:面积和 Hu 矩的数值尺度差了几个数量级,距离计算被面积主导。 解决:所有特征送入分类器前必须经过标准化。这本身不难,难的是记得在每次训练前都做一遍,特别是你往特征向量里加新特征的时候,最容易忘掉重跑 fit。
5.5 训练集和测试集来自同一批照片
现象:交叉验证准确率 95%,部署到新照片上只有 60%。 原因:同一次拍摄的连续照片几乎一样,随机切分后同一批样本同时出现在训练集和测试集,评估虚高。 解决:按拍摄批次划分数据集。比如你有 3 批照片,就用 2 批训练、1 批测试,然后轮换验证。这个教训是我在模拟项目X里被新照片结果狠狠教育之后总结出来的,宁可训练样本少一点,也要保证测试集够独立。
6. 进阶:批量识别与参数外置
6.1 批量文件夹识别脚本
把单张预测封装好后,批量识别只是加一个文件遍历循环的事。我习惯把结果写成 CSV 而不是直接打屏,方便后续分析哪一类出错最多。
import csv import os def batch_predict(folder_path, model, scaler, labels, output_csv): results = [] for img_name in os.listdir(folder_path): if not img_name.lower().endswith(('.jpg', '.png', '.jpeg')): continue img_path = os.path.join(folder_path, img_name) pred = predict_leaf(img_path, model, scaler, labels) results.append([img_name, pred]) with open(output_csv, 'w', newline='', encoding='utf-8') as f: writer = csv.writer(f) writer.writerow(['image_name', 'prediction']) writer.writerows(results)批量识别时最值得先做的一件事是导出混淆矩阵,它告诉你在当前数据分布下,哪些类别容易被混淆。比如柳树和杨树频繁被分混,那就回到特征上检查这两类的颜色均值和宽高比分布,看是否需要加特征。盲目加数据是效率最低的优化方式,先看错在哪。
6.2 把 HSV 范围做成可配置参数
不同光源环境下的叶片,H、S、V 的最优范围差异很大。我在项目里把分割参数抽成一个 JSON 配置文件,换场景时只改文件不改代码。一个典型的配置长这样:
{ "h_lower": 35, "h_upper": 85, "s_lower": 40, "s_upper": 255, "v_lower": 25, "v_upper": 255, "median_blur_kernel": 5, "morph_kernel_size": 7 }把这些参数做成可配置的,还有一个额外的好处:你可以写一个简单的 GUI 程序,用轨迹条实时调整阈值、实时刷新分割预览,找到合适参数组之后存成配置文件。这套做法在多变环境下的调参效率比改代码重启高太多。
6.3 用深度学习特征替代手工特征的混合方案
当手工特征提到瓶颈、比如准确率卡在 80% 上不去时,我建议不要推翻重来,而是把手工特征和预训练 CNN 的特征拼接。做法是用一个在 ImageNet 上预训练过的模型(如轻量的 MobileNet)提取倒数第二层的特征向量,然后和你的手工特征向量拼接后送给 SVM。这样既保住了手工特征的可解释性,又引入了 CNN 的强表征能力,样本量小的时候比微调整个网络更不容易过拟合。
这套方案的落地成本和纯手工方案差不了太多,但准确率通常能拉升 5 到 10 个百分点。我在另一个模拟项目X里就是这么做的,把 18 类植物叶片从 82% 提到了 93%。顺序上我还是建议先把手工方案跑通、把数据组织好,再考虑加 CNN 特征。直接跳跃到深度学习的最大风险是你调不好,也说不清为什么错。
写到最后分享一个教训:整套流程里最容易被低估的是数据采集,而不是算法。我第一版模型在实验室图片上跑到了 94%,放到手机随手拍的照片上直接跌到 65%。后来换了更接近真实使用场景的数据重新训练,才把数字拉回来。做叶片识别,先用传统方案把每个环节吃透,遇到瓶颈再往深度学习方向走,这条路比一上来就怼网络要稳得多。希望帮到你。
本文还有配套的精品资源,点击获取