简介:Python-OpenCV 植物叶片识别实验文档是一份面向计算机视觉初学者的课程实验材料,主要针对图像分割、表示与特征提取这一核心任务,帮助读者理解如何利用 OpenCV 完成叶片区域的轮廓检测与形状分析。压缩包内仅含 1 个 doc 格式文档,体积约 331KB,内容按实验目的、实验要求、原理讲解与代码示例组织,适合作为图像处理课程的配套参考资料。文档系统介绍了 cv2.findContours()、cv2.drawContours() 等常用函数,并通过具体代码演示二值化、阈值处理、轮廓查找与绘制的完整流程;同时详细讲解了轮廓的面积、周长、矩、质心、边界框、最小外接圆等特征的计算方法,并比较了直边界矩形与旋转边界矩形在适用场景上的区别,还补充了长宽比、矩形面积比、凸包面积比等扩展特征。已有 5414 人浏览/学习该资源,这一热度说明它在植物叶片识别入门和 OpenCV 轮廓特征学习方面具有较好的实践参考价值。按文档完成实验后,读者可具备独立编写叶片图像轮廓提取与特征计算脚本的能力,为后续更复杂的图像分析任务打下基础。
1. 植物叶片识别项目的第一关:先把 OpenCV 轮廓玩明白
做 Python-opencv 植物叶片识别时,最容易走的弯路是上来就套深度学习。某高校图像处理课上那份叶片实验,恰恰用的是相反的思路:不训练任何模型,只靠 cv2.findContours 找轮廓、cv2.moments 算特征,再加上长宽比、凸包面积比、极点位置,就能把 leaf.jpg 到 leaf5.jpg 分成不同类。这套资源适合正在做课程设计、需要快速交付一个可演示叶片分类程序的人;它的核心价值不在算法新颖,而在于把图像分割、表示与特征提取的完整链条,用 6 张真实叶片图串成了一条能跑通、能改参数、能写进报告的逻辑。我拆这套实验时最大的感触是:轮廓相关函数的版本坑、阈值坑、索引坑,比算法本身更能决定你今晚能不能跑出结果。
2. 轮廓查找与绘制:二值化预处理和 cv2.findContours 参数详解
2.1 为什么必须先做二值化:黑底白物原则
找轮廓之前必须做阈值化或者 Canny 边缘检测,这不是流程仪式,而是 findContours 的工作原理决定的。它从一张二值图里提取"白色物体"的边界,所以你给它的图里,要找的叶片必须是白色,背景必须是黑色。OpenCV 对这种操作有一个很形象的描述:查找轮廓就像在黑色背景中找白色物体。
实验材料里的 leaf.jpg 是典型的白底绿叶,直接读灰度图后做全局阈值处理就行。这里有个新手最容易忽略的细节:cv2.threshold 的 THRESH_BINARY 模式会把大于阈值的像素置为 255(白色),小于阈值的置为 0(黑色)。如果白底绿叶直接套这个模式,背景反而变成白色,叶片变成黑色,轮廓提取方向就反了。我一般会先打印灰度直方图看一眼,或者干脆用 THRESH_BINARY_INV 反转,这两种处理在下面的代码里都能看到。
import cv2 import numpy as np img = cv2.imread('leaf.jpg') # BGR 彩色图 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 转灰度 # 127 是硬阈值,THRESH_BINARY 会把 >127 的像素置 255 ret, thresh = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY) # 如果叶片是深色、背景是浅色,用下面这行替代上一行 # ret, thresh = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY_INV) cv2.imshow('gray', gray) cv2.imshow('thresh', thresh) cv2.waitKey(0) cv2.destroyAllWindows()这段代码的逻辑很直接:imread 读进来的是三通道 BGR 图,cvtColor 转成单通道灰度,threshold 把灰度图变成黑白二值图。阈值 127 是经验值,意思是亮度大于 127 的像素判为白,小于等于 127 判为黑。cv2.waitKey(0) 是让窗口停住,按任意键继续,这一步在交互调试时非常有用,别省。
2.2 findContours 的三个参数与检索模式选择
cv2.findContours 是整套实验的地基,三个参数分别控制输入图像、轮廓检索模式、轮廓近似方法。实验报告里有人图省事写成 cv2.findContours(thresh, 1, 2),用数字代替枚举,这在语法上能跑,但工程里我强烈建议写完整的枚举名,不然三个月后自己都看不懂当初的 1 和 2 是什么意思。
# OpenCV 3.x 写法:返回 image, contours, hierarchy 三个值 _, contours, hierarchy = cv2.findContours( thresh, cv2.RETR_EXTERNAL, # 只取最外层轮廓,忽略内部孔洞 cv2.CHAIN_APPROX_SIMPLE # 压缩冗余点,只保留端点 ) print(type(contours), len(contours))contours 是一个 Python 列表,每个元素是一个 Numpy 数组,形状是 (N, 1, 2),N 是轮廓点数,每个点存 x、y 坐标。RETR_EXTERNAL 是检索模式,只拿外层轮廓,适合叶片这种主体目标;如果你要统计叶片上的虫洞、叶脉这类内部结构,才需要换成 RETR_TREE 拿到层级关系。CHAIN_APPROX_SIMPLE 是近似方法,它把一条直线上的点压缩成首尾两个端点,极大减少内存占用;如果换成 CHAIN_APPROX_NONE,所有边界点都会保留,轮廓点数会多一个数量级,但后续算特征时几乎没有精度收益。
这里必须提醒一个版本差异。OpenCV 3.x 的 findContours 返回三个值,OpenCV 4.x 只返回 contours, hierarchy 两个值。同样一行代码,换个环境就报ValueError: not enough values to unpack,这是这个实验里最常见的翻车现场。兼容写法我在第 5 章的避坑部分会给出。
2.3 绘制轮廓:画到副本上,别污染原图
实验指导里特意强调"查找轮廓的函数会修改原始图像",这个说法在 OpenCV 3.x 下是真的——findContours 的底层实现会修改输入图像,所以官方建议把原图存到其他变量里。虽然 4.x 不再修改输入,但保留这个习惯没有坏处。
canvas = img.copy() # 复制一份再画,原图留给后续步骤 cv2.drawContours(canvas, contours, -1, (0, 0, 255), 2) cv2.imshow('contours', canvas) cv2.waitKey(0) cv2.destroyAllWindows()drawContours 的第二个参数传轮廓列表,第三个参数是索引,-1 表示绘制全部轮廓;如果只想画第 0 个轮廓,写成 0 即可。颜色是 (0, 0, 255) 的 BGR 元组,这里是大红色,厚度 2 是线宽像素。绘制在 img.copy() 上而不是 img 上,是为了后面还要用原始图做边界框叠加,不被轮廓线污染。
| 参数 | 取值 | 作用 |
|---|---|---|
| mode | RETR_EXTERNAL | 只取最外层轮廓,适合叶片主体 |
| mode | RETR_TREE | 提取全部轮廓并建立层级关系,适合带虫洞叶片 |
| mode | RETR_LIST | 提取全部轮廓但不建层级,速度最快 |
| method | CHAIN_APPROX_SIMPLE | 压缩直线段,只存端点,推荐 |
| method | CHAIN_APPROX_NONE | 保存全部边界点,点数多 |
3. 叶片特征量化:用图像矩、面积和周长描述一片叶子
3.1 图像矩:m00 是面积,m10/m01 找质心
图像矩是从概率统计里借来的概念,把轮廓像素当分布,零阶矩是总质量(面积),一阶矩除以零阶矩就是质心。cv2.moments() 返回一个字典,这是整个实验里性价比最高的一个函数——一次调用,面积、质心全都有了,还不用额外遍历像素。
# 取面积最大的那个轮廓,通常是叶片主体 cnt = max(contours, key=cv2.contourArea) M = cv2.moments(cnt) print(M['m00']) # 0 阶矩:轮廓面积,单位是像素 cx = int(M['m10'] / M['m00']) # 质心 x 坐标 cy = int(M['m01'] / M['m00']) # 质心 y 坐标 # 在质心画一个小圆 cv2.circle(img.copy(), (cx, cy), 5, (0, 255, 0), -1)逻辑在这里很顺:moments 先算出所有阶的矩,再用 m10/m00 和 m01/m00 归一化得到质心坐标。这一步对后面的叶片分类有实际意义——不同种类的叶片,质心相对几何中心的位置不一样,比如尖头叶的质心会偏向叶尖一侧,圆叶的质心基本居中。int() 取整是因为画圆函数需要整数坐标。
3.2 面积与周长:两个函数各自的隐藏细节
面积和周长是轮廓最基础的两个量,但 cv2.contourArea 有个冷知识:它返回的是带符号值,符号取决于轮廓点的排列方向是顺时针还是逆时针。绝大多数场景下取绝对值即可。周长则要注意 cv2.arcLength 的第二参数,True 表示曲线闭合,计算时会自动把首尾点连上;False 表示当成一条开放的曲线,差了一条边的距离。
area = abs(cv2.contourArea(cnt)) # abs 去掉方向符号 perimeter = cv2.arcLength(cnt, True) # True 表示轮廓是闭合的 print(f"area={area}, perimeter={perimeter}") # 圆形度:面积与周长的平方比,圆最大 circularity = 4 * np.pi * area / (perimeter * perimeter) print(f"circularity={circularity}")圆形度这个复合特征值得多说两句。它无量化单位,不受图像缩放影响,阔叶的圆形度通常大于 0.5,细长叶、针叶会掉到 0.3 以下。实验里的 leaf1.jpg 和 leaf2.jpg 如果一张是卵形叶一张是披针形叶,靠这个量就能粗分。area 和 perimeter 是像素单位的绝对值,同一个叶片拍近拍远数值差很多,所以分类时一定要用无量纲比值,这是实验指导里没明说但实际最重要的经验。
3.3 把特征拉成表:6 张叶片图的批量测试
实验要求对 leaf.jpg 到 leaf5.jpg 全部跑一遍并记录结果。手工一张张跑太慢,我习惯写一个循环,把每张图的轮廓特征打印成一行文本,顺便过滤掉噪声小轮廓。
import glob for path in glob.glob('leaf*.jpg'): img = cv2.imread(path) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) _, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: continue c = max(contours, key=cv2.contourArea) a = cv2.contourArea(c) p = cv2.arcLength(c, True) if a < 500: # 过滤面积小于 500 像素的噪点 continue circ = 4 * np.pi * a / (p * p) print(f"{path}: area={a:.0f}, perm={p:.0f}, circularity={circ:.3f}")这段代码里出现了两个工程上的讲究。一是用 Otsu 自动阈值替代固定 127,glob 循环里每张图的亮度分布不一样,写死的阈值早晚会翻车;二是 max(contours, key=cv2.contourArea) 取最大轮廓,把背景碎块、阴影噪点挡在门外。print 的浮点格式化也顺手把输出压成一行,多张图横向对比时非常直观。
4. 外接几何:直边界框、旋转矩形和最小外接圆的取舍
4.1 直边界框:boundingRect 适合竖直摆放的叶片
边界框是轮廓的"包围盒",实验里讲了直边界框和旋转边界框两种。cv2.boundingRect 返回的是一个与坐标轴对齐的矩形,不管叶片怎么旋转,它都按 x、y 轴方向框。这个矩形的面积不一定是包围这个轮廓的最小矩形,但胜在计算快、直观,适合叶片本身接近竖直摆放的场景。
x, y, w, h = cv2.boundingRect(cnt) img_box = img.copy() cv2.rectangle(img_box, (x, y), (x + w, y + h), (0, 255, 0), 2) cv2.imshow('bounding rect', img_box) cv2.waitKey(0)x、y 是矩形左上角坐标,w、h 是宽和高。cv2.rectangle 接收左上角和右下角两个点,所以右下角要自己算成 (x+w, y+h)。这一步的逻辑就是把几何信息转成绘图坐标,后面算长宽比时 w/h 直接用这两个值。
4.2 旋转边界框:minAreaRect 与 boxPoints 的版本坑
旋转边界框考虑叶片旋转,用 cv2.minAreaRect 求的是最小面积矩形。这个函数返回一个 Box2D 结构,包含中心坐标、宽高和旋转角度。但要注意,学校里老教材写的是 cv2.cv.BoxPoints,OpenCV 3.x 之后改成 cv2.boxPoints,名字少了中间那层 cv,很多人抄老代码直接报 AttributeError。
rect = cv2.minAreaRect(cnt) # 返回 ((cx,cy), (w,h), angle) box = cv2.boxPoints(rect) # 得到 4 个角点,浮点坐标 box = np.int0(box) # 转成整数供 drawContours 使用 img_rot = img.copy() cv2.drawContours(img_rot, [box], 0, (0, 0, 255), 2) cv2.imshow('rotated rect', img_rot) cv2.waitKey(0)boxPoints 返回的 4 个角点是无序的,drawContours 需要你把它作为轮廓列表传进去。np.int0 是把浮点数组截断成整数,这个转换不做的话,drawContours 会报类型错误,这是旋转框绘制里最常见的报错点。旋转框的特点是面积最小,适合斜着放的叶片,比如 leaf3.jpg 如果是 45 度角拍的,直框会框进大量空白区域,旋转框能贴得更紧。
4.3 最小外接圆与椭圆方向:尺度归一化的起点
cv2.minEnclosingCircle 找的是能包住轮廓的最小圆,返回圆心和半径。它的价值不只是画一个圆,更在于提供了一个和叶片旋转无关的尺度基准——用圆的半径做归一化,长宽比、面积这些特征就能在不同拍摄距离下对比。
(x, y), radius = cv2.minEnclosingCircle(cnt) center = (int(x), int(y)) radius = int(radius) img_circle = img.copy() cv2.circle(img_circle, center, radius, (255, 0, 0), 2) cv2.imshow('min enclosing circle', img_circle) cv2.waitKey(0) # 椭圆拟合:得到长短轴和旋转角 ellipse = cv2.fitEllipse(cnt) cv2.ellipse(img.copy(), ellipse, (0, 255, 255), 2)fitEllipse 返回 (中心点, 长短轴, 旋转角),它能把叶片拟合成一个椭圆,长短轴之比直接反映叶片是细长还是圆润,旋转角反映叶片的朝向。实验 2.6.5 里要求做"对象的方向",就是靠这个函数。实际用的时候注意,轮廓点数至少要 5 个以上才能做椭圆拟合,面积滤波之后的叶片轮廓通常有几百个点,完全够。
| 外接方式 | 函数 | 特点 | 适用场景 |
|---|---|---|---|
| 直边界框 | boundingRect | 与坐标轴对齐,计算快 | 叶片竖直放置 |
| 旋转边界框 | minAreaRect + boxPoints | 面积最小,贴合旋转目标 | 斜拍叶片 |
| 最小外接圆 | minEnclosingCircle | 尺度归一化基准 | 不同距离拍摄对比 |
| 椭圆拟合 | fitEllipse | 长短轴 + 方向角 | 叶片朝向和细长度 |
5. 叶片分类常见问题:五个高频翻车点,对照排查
5.1 阈值选错,叶片和背景糊成一片
现象:leaf.jpg 跑出来轮廓画满了整张图,叶片边缘根本找不到,或者找出来的"叶片"轮廓比实际大好几十倍。
原因:白底绿叶的图直接套 THRESH_BINARY,背景被置成 255 白色,叶片被置成 0 黑色,而 findContours 只认白色物体,等于在找背景而不是找叶片。
解决:先看灰度直方图再定阈值,最省事的做法是直接用 Otsu 自动阈值,或者手动用 THRESH_BINARY_INV 反转。我一般会对同一批实验图写死一个函数,内部统一走 Otsu:
# Otsu 自动阈值,避免每张图手动调 127 ret, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)参数含义:0 是阈值初值,Otsu 会忽略它,算法自动在灰度直方图上找最优分割点。注意要加 cv2.THRESH_OTSU 这个标志位,光写 THRESH_BINARY 它不会自动算阈值。
5.2 findContours 返回值数量随 OpenCV 版本变化
现象:同一份代码,在自己电脑上跑得好好的,换到同学电脑上报ValueError: not enough values to unpack,或者反过来多了一个值。
原因:OpenCV 3.x 的 findContours 返回 image, contours, hierarchy 三个值,4.x 返回 contours, hierarchy 两个值。实验报告里的示例代码是 3.x 写法,你的环境是 4.x 就直接翻车。
解决:用 try/except 兼容两个版本,或者直接按 4.x 写法接收两个值,3.x 环境下用下划线丢第一个:
try: contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) except ValueError: _, contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)这个写法丑但稳。实验报告交付时最好在开头注明 OpenCV 版本,不然评测老师的环境和你不一致,程序连启动都做不到。
5.3 背景噪点被当成轮廓,特征全被污染
现象:drawContours 之后画面上多了十几个小轮廓,有的是边框阴影,有的是阈值分割留下的碎块。max(contours, key=cv2.contourArea) 取到的"最大轮廓"其实是背景区域的一部分。
原因:光照不均匀、叶片边缘有阴影、或者原始图本身背景就不干净。阈值处理只管亮度,不管连通性,背景里的暗斑一旦和叶片粘连,就会被当作同一个轮廓处理。
解决:加面积滤波,只保留面积大于阈值的轮廓。更稳妥的做法是先找所有轮廓,再按面积排序取前几个,不要默认 contours[0] 是目标:
contours = [c for c in contours if cv2.contourArea(c) > 800] if not contours: continue cnt = max(contours, key=cv2.contourArea)面积门槛 800 是经验值,和图片分辨率有关。如果叶片占满全图,门槛可以放到 5000;如果叶片很小,800 可能把目标也滤掉。调参时打印出所有轮廓的面积列表,一眼就能看出该设多少。
5.4 minAreaRect 的绘制结果错位或报错
现象:旋转矩形画出来的位置完全不对,或者报TypeError: function takes exactly 2 arguments之类的错误。
原因:两个问题叠加。一是 cv2.boxPoints 返回的是浮点坐标,drawContours 要求整数;二是 2.x 老代码里的 cv2.cv.BoxPoints 在新版本里不存在。
解决:统一用 cv2.boxPoints,输出后立刻 np.int0 转整数。还有一个隐藏细节,boxPoints 返回的 4 个点顺序不是固定顺序,画出来可能出现菱形交叉,这时把绘制的轮廓索引从 0 改成 -1,或者把 box 数组按顺序重新排列,都能解决。
5.5 掩模提取像素点失败,轮廓没有填充
现象:2.6.6 节里用 mask 提取轮廓内像素点,结果 np.nonzero(mask) 只返回了边框一圈的点,根本没有内部像素。
原因:cv2.drawContours 的厚度参数默认是 1,只画轮廓线不填充内部。掩模操作需要的是整个轮廓内部的像素,必须把厚度设为 -1 表示填充。
解决:把厚度参数写成 -1,并且注意掩模的尺寸要和原灰度图一致:
mask = np.zeros(gray.shape, np.uint8) cv2.drawContours(mask, [cnt], 0, 255, -1) # -1 表示填充 # 之后就能用掩模做各种统计了 mean_val = cv2.mean(img, mask=mask) # 叶片平均颜色 min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(gray, mask=mask)这个坑的隐蔽之处在于不报错,只是结果不对。很多人看到像素点只有一圈,会以为是轮廓检测的问题,折腾半天才发现是填充参数没写对。从那以后我每次写 drawContours 到掩模上,都会条件反射地检查厚度参数是不是 -1。
6. 把轮廓性质装进轻量分类器:朝向归一化与阈值表
实验的最后要求是综合多个叶片特征做自动分类。我倾向于不训练模型,直接把第 3、4 章算出来的特征拼成一个特征向量,用阈值查表判断。OpenCV 的轮廓性质里有一组现成的无量纲特征,正好适合做这件事。
| 特征 | 计算方式 | 物理含义 |
|---|---|---|
| 长宽比 | boundingRect 的 w/h | 叶片细长程度 |
| extent | 轮廓面积 / 边界框面积 | 叶片占边界框的饱满度 |
| solidity | 轮廓面积 / 凸包面积 | 叶缘锯齿、缺刻程度 |
| 等效直径 | sqrt(4*area/pi) | 和面积相等的圆直径 |
| 椭圆长短轴比 | fitEllipse 返回的 MA/ma | 整体细长度 |
分类器的逻辑可以做成一个逐级判断的树:先算 solidity,如果小于 0.8 说明叶缘有明显缺刻;再算长宽比,大于 2 划到细长叶;最后用圆形度区分卵形叶和近圆形叶。每一级判断都打印出特征值,方便调试。视频输入时取每一帧的最大轮廓,帧间抖动只需要做简单的面积平滑,分类阈值不需要跟着变。
我自己的验收习惯是用摄像头对准一张叶片图,程序输出类别后再把轮廓、边界框、外接圆画在同一帧上,肉眼确认特征值和实际叶片形状是否对得上。这个步骤看起来麻烦,但能一次暴露二值化方向、轮廓索引、归一化尺度三个层面的问题。
最后补一个血泪教训:这套实验里所有的阈值和面积门槛都不要写死在代码深处,统一放到文件开头用变量定义。从那次把阈值写死翻车之后,我每次复现这套流程都强制先看一眼灰度直方图,用 Otsu 兜底,再进 findContours,最后才调分类阈值。希望帮到你。
本文还有配套的精品资源,点击获取