☰
OpenCV轮廓特征驱动植物叶片识别:从二值化到最近邻分类
2026/10/23 3:22:31 网站建设 项目流程

简介:面向Python与OpenCV学习者的植物叶片识别实验文档,围绕图像分割、表示与特征提取展开,适合正在完成图像处理课程实验或入门计算机视觉的读者。资源共1个doc文件,压缩包大小331KB,内容精炼集中,已有5414人下载学习。文档以实验报告形式组织,涵盖实验目的、要求与结果,从轮廓概念与二值化预处理讲起,逐步演示cv2.findContours()查找轮廓、cv2.drawContours()绘制轮廓,并计算矩、面积、周长等基础特征。进一步介绍直边界矩形、旋转边界矩形和最小外接圆的求解方法,同时补充长宽比、轮廓与边界矩形面积比、轮廓与凸包面积比、等效圆直径、拟合椭圆方向、掩模像素点及平均灰度等轮廓性质的计算代码。通过叶片图像的阈值分割与二值化处理,读者可以掌握用OpenCV对植物叶片进行轮廓识别、特征提取与定量分析的完整流程,尤其适合作为课程实验参考或相关项目起步材料。

1. 植物叶片识别项目:OpenCV 轮廓特征驱动的自动分类实验

每年到了图像处理课程后期,总有一批同学被同一个综合作业卡住:给定几张植物叶片图,要程序自动说出叶片种类。单纯做模板匹配显然不现实——叶片有旋转、有尺度变化,光照也不均匀,硬套相似度算法很容易翻车。我拆的这份 Python + OpenCV 植物叶片识别实验,走的是另一条更稳的路线:不碰深度学习,只靠轮廓特征(长宽比、矩形度、圆形度、凸度、等效直径)组合出分类向量,再用最近邻规则做判别。整套代码从读取 leaf.jpg 到输出叶片类别,核心链路就是「二值化 → 找轮廓 → 提特征 → 查表分类」,每一层都有对应的 OpenCV 函数可以直接落点。它特别适合两类人:一是刚学完轮廓相关 API 想找个完整闭环练手的初学者,二是需要快速给叶片图像做形状分析,又不想引入重型框架的开发者。

2. 轮廓提取链路:为什么先分割再谈识别

2.1 二值化是轮廓提取的前置条件

实验文档里反复强调一个观点:找轮廓之前必须先做阈值化或者 Canny 边缘检测。原因很直接——cv2.findContours()处理的是「黑色背景上的白色物体」,如果直接传入彩色图,灰度值分布复杂,轮廓提取会得到大量噪声边界,后续的特征计算全部失真。实验里用到的cv2.threshold()是固定阈值法,只适合叶片与背景灰度差异明显的图片;遇到背景复杂的实拍图,我会先转 HSV 用绿色通道做 inRange 提取叶片区域,效果比全局阈值稳定得多。

import cv2 import numpy as np # 读取叶片灰度图 img = cv2.imread('leaf.jpg', 0) # 全局阈值分割:像素值大于120的置为255(白),其余置为0(黑) ret, thresh = cv2.threshold(img, 120, 255, cv2.THRESH_BINARY) # 可视化分割结果 cv2.imshow("threshold", thresh) cv2.waitKey(0)

这段代码的参数含义要拆开说:120是阈值,实验素材里叶片与背景灰度差通常在 40 以上,取 120 能把叶片完整摘出来;255是目标像素值,表示满足条件的像素统一赋成白色。cv2.THRESH_BINARY是正向二值化,即大于阈值的变白、小于阈值的变黑。如果你发现叶片内部出现黑色空洞,说明阈值偏高,把叶片上的浅色区域误判成了背景;反之背景噪点变多,则是阈值偏低。这里有个坑:固定阈值对每张图都要手工调参,后面做批量分类时,我会对每张图单独算一个 Otsu 自适应阈值,避免一张图一个参数的尴尬。

2.2 轮廓查找与绘制:理解返回值和层级

cv2.findContours()是整条链路的枢纽。实验文档里写着返回值有三个——图像、轮廓、层级,但这里有个版本差异必须先说明:OpenCV 3.x 之后,第一个返回值不再是图像,而是轮廓本身,hierarchy变成第二个返回值。如果你照着旧教程写contours, hierarchy = cv2.findContours(...),在 OpenCV 4.x 下会直接报错,这是我跑这份实验遇到的第一个坑。

# 查找轮廓:RETR_TREE 返回完整层级,CHAIN_APPROX_SIMPLE 只保留端点 contours, hierarchy = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) # 在原始图像的副本上绘制全部轮廓,绿色,线宽2 img_contour = cv2.drawContours(img.copy(), contours, -1, (0, 255, 0), 2) cv2.imshow("contours", img_contour) cv2.waitKey(0)

RETR_TREE会把所有轮廓按父子层级组织起来,比如叶片外轮廓是父级,叶片内部的叶脉空洞轮廓是子级;CHAIN_APPROX_SIMPLE则压缩轮廓点,只保留端点。参数说明上,-1表示绘制所有轮廓,想单独画某一个就把索引传进去。实验里强调「查找轮廓会修改原始图像」,是因为findContours内部会改动传入的二值图,所以绘制时我用img.copy()保护原图,这是血泪经验——否则后面提取特征时拿到的就是被改过的图。

3. 特征工程实战:从矩到多维度形状描述

3.1 矩与基础几何量:面积、周长、质心

轮廓本身是一堆坐标点,直接比较坐标没有意义,需要把坐标点压缩成几个数值。OpenCV 提供的cv2.moments()就是干这个的——它计算轮廓的几何矩,返回一个字典,其中m00是零阶矩,代表轮廓面积;m10 / m00和m01 / m00分别是质心的 x、y 坐标。实验文档里用M['m00']替代cv2.contourArea(),两者在数学上等价,但m00是从矩推导的,能跟中心矩统一起来用。

# 取第一个轮廓作为研究对象 cnt = contours[0] M = cv2.moments(cnt) area_moment = M['m00'] # 零阶矩 = 面积 cx = int(M['m10'] / M['m00']) # 质心x坐标 cy = int(M['m01'] / M['m00']) # 质心y坐标 area = cv2.contourArea(cnt) # 直接计算面积 perimeter = cv2.arcLength(cnt, True) # 闭合轮廓周长

cv2.arcLength()的第二个参数True表示轮廓是闭合的;如果传False,它只计算折线长度。做叶片识别时,叶片轮廓天然闭合,必须用True,否则周长会少算一段闭合边的距离。面积和周长这两个量本身不能直接用于分类——同一个叶片拍近拍远,面积差好几倍,这就是尺度敏感问题。解决办法是后面把面积换成「面积占边界矩形比例」这类归一化特征。

在实验素材里出现过contours, hierarchy = cv2.findContours(thresh, 1, 2)这种写法,1和2分别对应RETR_EXTERNAL和CHAIN_APPROX_SIMPLE的数值形式。我只建议在交互式调试时用数字,正式代码必须写常量名,否则换 OpenCV 版本时数字含义变动,代码就成了黑匣子。

3.2 边界矩形与圆形度:归一化特征提取

只靠面积和周长做分类,叶片角度一变结果就飘。实验给出了两类边界框来解决这个问题:直边界矩形cv2.boundingRect()不考虑旋转,得到的宽高比会随叶片角度变化;旋转边界矩形cv2.minAreaRect()返回最小外接矩形,包含中心点、宽高和旋转角,它能更稳定地描述叶片的伸展方向。

# 直边界矩形(轴对齐) x, y, w, h = cv2.boundingRect(cnt) aspect_ratio = float(w) / h # 长宽比特征 rect_area = w * h extent = area / rect_area # 轮廓面积占边界矩形面积的比例 # 最小外接矩形(考虑旋转) rect = cv2.minAreaRect(cnt) box = cv2.boxPoints(rect) # 获取旋转矩形的4个角点 box = np.int0(box) # 等效圆直径:与轮廓面积相等的圆的直径 equi_diameter = np.sqrt(4 * area / np.pi)

aspect_ratio描述叶片是狭长还是宽圆,不同植物差异明显;extent描述轮廓在矩形里的填充程度,叶片边缘如果是深裂的,extent 会偏小,比如枫叶类裂叶的 extent 通常低于 0.6,而完整椭圆叶能到 0.8 以上。equi_diameter用于排除尺度影响——它是面积的另一种表达,本身仍受缩放影响,一般会和周长一起构造「圆形度」特征。真正的圆形度公式是4 * pi * area / (perimeter * perimeter),取值越接近 1 形状越接近圆,这个特征对叶片形态区分度非常高,实验中虽然没直接给公式,但从面积、周长能组合出来。

3.3 凸包特征:叶片裂痕的量化指标

实验里有一组容易被忽略的特征——solidity(轮廓面积与凸包面积之比)和cv2.convexHull()。凸包可以理解为包裹轮廓的最小凸多边形。叶片如果有锯齿或深裂,凸包面积会明显大于轮廓实际面积,solidity 就会小于 1;光滑叶片 solidity 接近 1。

hull = cv2.convexHull(cnt) hull_area = cv2.contourArea(hull) solidity = area / hull_area # 凸度特征:越接近1叶片越饱满 # 极点:轮廓的最上、最下、最左、最右点 leftmost = tuple(cnt[cnt[:, :, 0].argmin()][0]) rightmost = tuple(cnt[cnt[:, :, 0].argmax()][0]) topmost = tuple(cnt[cnt[:, :, 1].argmin()][0]) bottommost = tuple(cnt[cnt[:, :, 1].argmax()][0])

极点坐标本身不直接参与分类,但可以用来算叶片长度和宽度,结合最小外接矩形的角度,能估计叶片的主轴方向。我在做实验时发现极点的取法有个细节:cnt[:, :, 0]取所有点的 x 坐标,argmin()返回最小值的索引,因为轮廓点的形状是(n, 1, 2),所以要加[0]才能取到正确坐标。如果不做这一步,得到的极点是数组元组,绘制时会报维度错误。

4. 自动分类核心:构建叶片特征向量与判别规则

4.1 三类特征组合:把实验中的量变成可分类的向量

综合作业要求「综合使用多个叶片特征」构建自动分类算法。实验正文提供了九类轮廓性质,但直接全部塞进分类器会出问题——量纲不统一,比如周长是几百像素、长宽比是 1.5,简单距离计算会被周长主导。我的做法是只挑归一化特征:长宽比、extent、solidity、圆形度,外加等效直径除以周长得到的「形状紧凑度」。

def extract_features(contour): # 面积与周长 area = cv2.contourArea(contour) perimeter = cv2.arcLength(contour, True) if perimeter == 0: return None # 长宽比(基于直边界矩形) x, y, w, h = cv2.boundingRect(contour) aspect_ratio = w / float(h) # 矩形度:面积占边界矩形面积比例 extent = area / (w * h) # 凸度:面积占凸包面积比例 hull = cv2.convexHull(contour) hull_area = cv2.contourArea(hull) solidity = area / hull_area if hull_area > 0 else 0 # 圆形度:4πA/P² circularity = 4 * np.pi * area / (perimeter * perimeter) # 紧凑度:等效直径与周长之比 equi_diameter = np.sqrt(4 * area / np.pi) compactness = equi_diameter / perimeter return [aspect_ratio, extent, solidity, circularity, compactness]

这段代码把所有特征压到 0 到 1 之间或者无量纲区间,不同叶片图片的缩放不再影响特征值。extract_features输出一个长度为 5 的列表,这就是后续分类器的输入向量。有个细节需要注意:当轮廓只有一个点时perimeter为 0,会触发除零错误,所以加了if perimeter == 0的防护。实际实验中,噪声小区域也可能会被识别成轮廓,我建议在进入特征提取前加一个面积过滤——只保留面积大于 500 像素的轮廓,避免把背景杂质当成叶片。

4.2 分类器设计:为什么用最近邻而不是决策树

实验数据集只有 6 张叶片图,每类可能就是 1 到 2 个样本,决策树、SVM 这类需要大量样本训练的分类器在这里完全施展不开。我选用了最朴素的最近邻(1-NN)策略:离线提取各样本的特征向量存成数组,在线时提取未知叶片的特征,逐项算欧氏距离,距离最小的样本类别就是预测结果。

# 已知样本特征库(示例数据,实际从标注文件夹批量提取) X_train = [ [1.42, 0.76, 0.91, 0.62, 0.35], # 类A:阔叶 [3.10, 0.58, 0.85, 0.41, 0.28], # 类B:狭叶 [1.20, 0.82, 0.95, 0.71, 0.38], # 类C:圆叶 ] y_train = ['阔叶', '狭叶', '圆叶'] def predict_leaf(features): min_dist = float('inf') best_label = None for i, sample in enumerate(X_train): # 欧氏距离 dist = np.sqrt(sum((a - b) ** 2 for a, b in zip(features, sample))) if dist < min_dist: min_dist = dist best_label = y_train[i] return best_label, min_dist

predict_leaf的逻辑很简单:遍历每个已知样本,计算欧氏距离,取最小距离对应的标签。zip把待分类特征和样本特征按位配对,(a - b) ** 2算每个维度的差值平方。这个方案的好处是特征维度增加时不需要重新训练,加一组特征就多一个维度参与距离计算。缺点是每个特征维度默认权重相等,如果发现某个特征对分类特别重要,我一般会加权重系数,比如把 aspect_ratio 的权重设成 2.0,放大它对距离的贡献。

4.3 从视频帧到实时识别:改造思路

综合作业要求「通过视频获取叶片,程序自动报告植物种类」,这等于要把分类器接到视频流上。核心改造点有两个:一是视频帧进来后要做背景减除或颜色阈值,把叶片从复杂背景中分离;二是每帧都可能出现多个轮廓,需要按面积排序,只对最大轮廓做特征提取和分类。

cap = cv2.VideoCapture(0) while True: ret, frame = cap.read() if not ret: break # 转HSV并提取绿色区域 hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) mask = cv2.inRange(hsv, (35, 40, 40), (85, 255, 255)) mask = cv2.medianBlur(mask, 5) # 中值滤波去噪 contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if contours: # 取最大轮廓 cnt = max(contours, key=cv2.contourArea) area = cv2.contourArea(cnt) if area > 500: # 过滤小面积噪声 features = extract_features(cnt) label, dist = predict_leaf(features) cv2.drawContours(frame, [cnt], -1, (0, 255, 0), 2) cv2.putText(frame, label, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2) cv2.imshow("leaf recognition", frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()

这段实时代码中,cv2.inRange的 HSV 范围是关键参数,(35, 40, 40) 到 (85, 255, 255) 是绿色的通用范围,能覆盖大多数叶片颜色;如果叶片偏黄,可以把 H 下限降到 25;偏红则要调整到 170 以上。cv2.medianBlur去掉二值图上的椒盐噪声,避免把噪点轮廓当成叶片。max(contours, key=cv2.contourArea)是 Python 内置函数搭配 OpenCV 面积函数,一行实现「取最大轮廓」。最后用putText在画面左上角输出类别名,这就是「程序自动报告植物种类」的直观呈现。

5. 避坑记录:OpenCV 轮廓识别常见问题与排查

5.1 版本差异导致 findContours 返回值不匹配

现象:代码运行时抛出ValueError: not enough values to unpack (expected 3, got 2),定位到findContours这一行。

原因:OpenCV 3.x 之前findContours返回三个值(image, contours, hierarchy);3.x 之后改成了两个值(contours, hierarchy),原始图像不再返回。实验文档写的是旧版接口,直接照抄就会挂。

解决:统一按contours, hierarchy = cv2.findContours(...)解包,并确认环境里安装的是 OpenCV 3.0 以上版本。在代码顶部加一句print(cv2.__version__)先验版本,再决定解包方式,是我现在写 OpenCV 脚本的习惯。

5.2 背景目标和颜色方向反了

现象:阈值后叶片变成黑色,背景是白色,findContours找到的全是背景边框,特征计算出来全部异常。

原因:cv2.threshold默认THRESH_BINARY是「大于阈值为白」,如果叶片灰度低于背景,叶片会变黑。OpenCV 找轮廓的约定是「白色物体、黑色背景」,方向反了整个结果就是错的。

解决:先确认叶片的灰度范围。我常用的排查命令是np.unique(thresh)统计二值图的像素值分布,如果白色像素占比超过 80%,八成是方向反了。改用cv2.THRESH_BINARY_INV把黑白对调,或者把输入图取反cv2.bitwise_not(img)再二值化。

5.3 叶片内部空洞导致多层轮廓干扰

现象:叶片图像里有叶脉、斑点,findContours找出了几十个轮廓,最大轮廓面积远小于叶片实际面积。

原因:RETR_TREE模式返回所有层级轮廓,叶片内部的孔洞会生成子轮廓。如果不加过滤直接全部绘制,画面会非常乱。

解决:分类场景只用RETR_EXTERNAL获取最外层轮廓,或者用hierarchy的层级信息把子轮廓过滤掉。我在代码里加面积阈值area > 500是最简单的处理,但注意这个阈值要跟图片分辨率对齐——3000 像素宽的大图噪声面积 500 很正常,我一般会取图片总面积的 1% 作为过滤线。

5.4 非闭合轮廓导致周长和圆形度异常

现象:叶片边缘有缺口时,arcLength(cnt, True)计算出的周长偏大,圆形度特征明显偏小,分类结果不稳定。

原因:叶片边缘与背景灰度接近,阈值分割后轮廓断裂,一条完整边界被拆成多个开放轮廓。此时有些轮廓的起点和终点不重合,被当成闭合曲线计算周长会引入额外误差。

解决:在找轮廓之前做形态学操作,cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel)用闭运算把断裂处补上。kernel 大小一般设(5, 5),如果边缘缺口大可以调成(9, 9),但要注意过大会把相邻叶片连成一片。

5.5 minAreaRect 返回的宽高顺序不稳定

现象:同一叶片转 90 度后,rect[1]的宽和高互换,导致长宽比特征跳变。

原因:cv2.minAreaRect()返回的(center, (width, height), angle)中,width 和 height 是矩形的两条边,但 OpenCV 不保证 width 始终大于 height,角度在 0 到 90 度之间浮动时,宽高标签会交换。

解决:对返回的宽高做归一化处理,取long_edge = max(w, h); short_edge = min(w, h),用long_edge / short_edge作为长宽比特征。这样无论叶片怎么旋转,特征值保持稳定。这个坑花了我两个小时才定位到,当时分类结果在同一个叶片的垂直和水平拍摄图上差异巨大,查了半天才发现是宽高顺序在作怪。

6. 二次开发:把实验系统扩展成可用工具的三个改造点

6.1 特征库从硬编码改为目录自动提取

现在的X_train是写在代码里的硬编码数组,新增叶片类型就得改代码加一行。我一般会把样本图片按类别放进文件夹,启动时自动扫描提取特征:

import os X_train, y_train = [], [] for label in os.listdir('samples/'): label_path = os.path.join('samples/', label) if not os.path.isdir(label_path): continue for fname in os.listdir(label_path): img = cv2.imread(os.path.join(label_path, fname), 0) _, th = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) contours, _ = cv2.findContours(th, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if contours: cnt = max(contours, key=cv2.contourArea) X_train.append(extract_features(cnt)) y_train.append(label)

cv2.THRESH_OTSU是自适应阈值,它会根据图像灰度分布自动计算最优分割点,省去每张图手工调阈值的麻烦。这是从实验代码迈向实用工具的第一小步——我后来把所有叶子图都改成 Otsu 阈值后,准确率反而上升了,因为每张图的曝光条件不同,固定阈值反而会引入噪声。

6.2 特征维度诊断:哪些特征真的有用

把特征丢进分类器前,值得花时间做一次单特征区分度检查:把每个特征按类别画出分布,观察是否有重叠区间。我在这个实验里发现extent和solidity对叶片的锯齿边缘区分度最高,aspect_ratio对狭叶和阔叶非常敏感,而compactness在多数类别上分布重叠,单独使用价值不大。这个诊断过程能帮你决定要不要给特定特征加权重,或者直接剔除干扰维度。

在predict_leaf里我加过一组权重参数,效果明显的是把aspect_ratio权重调成 1.5,solidity调成 1.2。调整权重的依据不是瞎试,是看两类混淆样本在哪个维度上距离过近——如果两个不同叶片在长宽比上的差距远小于其他维度,就要把这个维度的权重降下来,让更稳定的特征主导决策。

6.3 完整封装:从脚本到命令行工具

把这个实验封装成命令行工具,使用户可以传入图片路径直接获取分类结果,是我做完实验后的第一件事。核心思路是保留extract_features和predict_leaf两个纯函数,用标准库argparse做参数解析,再加一层文件读写逻辑。注意特征库的持久化——用np.save('features.npy', X_train)和np.save('labels.npy', y_train)把耗时提取的特征存下来,下次运行时直接加载,避免每次都重新处理样本。

我踩过最深的坑是把X_train用普通列表存成文本再读回来,结果浮点精度丢失,分类结果出现偶发错判。改成 NumPy 二进制存储后问题消失。从那次以后,我每提取完一组特征,第一件事就是验证np.load读回来的数组和原始数组是否逐位相等,确认无误再进分类流程。这个习惯让我少走了很多弯路,也希望这次分享的完整链路——从二值化、轮廓提取、特征计算到最近邻分类——能帮你把自己的叶片识别程序真正跑通,让实验课的综合作业不再是玄学。

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

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

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

立即咨询