☰
Flavia叶片分类实战:从传统特征工程到ResNet迁移学习的完整避坑指南
2026/10/6 21:01:47 网站建设 项目流程

简介:这套Flavia叶片数据集分类项目源码与配套资料,主要面向机器学习、深度学习方向的高校学生和开发者,尤其适合作为课程设计、毕业设计或入门进阶的完整实践项目。项目在设计上兼顾传统机器学习与深度学习两条路线,既包含支持向量机等经典算法实现,又集成多种主流卷积网络及自建网络,并配有数据采集脚本和CAM可视化工具,可直观查看模型关注的叶片区域。压缩包共12个文件,以9个Python脚本为核心,覆盖数据读取、模型训练、可视化分析等环节;另有2个Markdown说明文档用于讲解项目结构,以及1份Word设计报告供撰写论文或答辩时参考。整个资源仅93KB,轻量紧凑,便于下载与快速部署。目前已有58人学习浏览,代码经过测试可正常运行。用户既可完整复现从数据准备到分类评估的全流程,也可在此基础上修改网络结构或加入新的想法,对于需要完成课程设计或入门图像分类的读者,是一份内容集中、实用性强的参考资料。

1. Flavia叶片分类:为什么一个32类级别的老数据集至今还有人翻车

Flavia叶片数据集在植物分类相关的机器学习任务里算得上一个经典资源:32个类别、1000多张叶片照片,规模小得连深度学习的脚趾头都喂不饱,却能同时考验传统机器学习特征工程和深度学习迁移学习两套基本功。我见过不少同学拿它练手,先跑手工特征加SVM,再换ResNet迁移学习,两轮下来把数据清洗、过拟合排查、归一化这些坑踩了个遍。这个数据集的价值不在数量,而在于把两条路线放在同一任务面前,特征怎么设计、模型怎么选、坑在哪,对比着看一目了然。下面按可复现的路径讲:数据准备、传统特征加分类器、CNN迁移学习、常见翻车点,最后是验证与进阶手法,这套顺序和对应源码里的模块划分基本一致。

2. 数据准备:Flavia目录结构、标签表与三个清洗习惯

在动手写任何模型之前,先把数据目录摸清楚。Flavia发布版最常见的组织形式是:一个大目录下有32个子文件夹,每个子文件夹对应一种植物,文件夹名一般是拉丁学名或英文通用名,里面的图片以数字编号命名;也有个别版本把所有图片平铺在一个目录里,另外附带一份label.txt或csv。第一步写个脚本把目录扫一遍,确认图片格式、尺寸、数量,这一步能避免后面出现"文件读不出来才去查数据"的被动局面。

import os import cv2 import pandas as pd data_dir = "Flavia" rows = [] for species in os.listdir(data_dir): sp_dir = os.path.join(data_dir, species) if not os.path.isdir(sp_dir): continue for fn in os.listdir(sp_dir): p = os.path.join(sp_dir, fn) img = cv2.imread(p) if img is None: print("无法读取:", p) continue rows.append({"species": species, "path": p, "h": img.shape[0], "w": img.shape[1]}) df = pd.DataFrame(rows) print(df["species"].value_counts())

cv2.imread返回None说明文件不是有效图像或路径含中文导致读取失败,统一打印出来而不是让程序崩掉。value_counts()能让你一眼看出每个类别的样本量分布;Flavia各类之间数量不完全相等,少的三十张左右、多的六七十张,这个不均衡在划分时会影响小类别的评估稳定性。脚本跑完后建议顺手把df保存成flavia_labels.csv,后面所有模块都从这份csv读取路径和标签,而不是每次重新遍历目录。

2.1 背景分离与ROI裁剪:白底图像不是天然干净

Flavia的原始图像大多拍摄于白色或浅色背景上,但别想当然以为背景是纯白。叶片边缘的阴影、桌面纹理、甚至叶片上的花斑都可能混入前景。传统机器学习对这类噪声很敏感,深度学习稍好一些,但统一做一次预处理能让两条线路在同一数据基础上对比。常见做法是先取HSV空间的V通道阈值做背景分离,再根据轮廓外接矩形裁剪ROI区域,同时把掩膜存下来供特征提取使用。

def get_leaf_roi(img_path, gray_thresh=220): img = cv2.imread(img_path) hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) v = hsv[:, :, 2] _, binary = cv2.threshold(v, gray_thresh, 255, cv2.THRESH_BINARY_INV) contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return img, img c = max(contours, key=cv2.contourArea) x, y, w, h = cv2.boundingRect(c) roi = img[y:y+h, x:x+w] mask = binary[y:y+h, x:x+w] return roi, mask

gray_thresh=220是个经验值,对多数Flavia白底图有效。若叶片本身浅黄或边缘透明,阈值要降到200以下;背景发灰则需要上调。我习惯先挑十来个类别各取两三张图跑一遍,把ROI和掩膜用拼接图肉眼看一圈再进训练管线,这步质检花不了两分钟,却比在训练完才发现大量裁剪错误高效得多。

2.2 标签表与类别编号:别把目录名直接当标签

Flavia的目录名是拉丁学名或英文通用名,直接交给LabelEncoder虽然能跑,但出混淆矩阵时全是拉丁名,阅读麻烦还容易拼错。建议先用固定映射把32个类别转为0到31的整数,同时保留一份species到中文名或描述性名字的对照表。这一步看似简单,却直接决定后续所有脚本里类别编号是否一致。

species_list = sorted(df["species"].unique()) species2id = {s: i for i, s in enumerate(species_list)} df["label"] = df["species"].map(species2id) df.to_csv("flavia_labels.csv", index=False)

先sort再enumerate,保证不同机器上类别顺序一致。后续做任何实验记录,类别编号都应以这份csv为准。全套资料里如果同时存在多份标签文件,务必核对它们是否由同一份排序代码生成,否则训练脚本和评估脚本的类别编号对不上,混淆矩阵会错位得很难察觉。

2.3 划分策略:先分层随机,再按类目检查小样本类别

Flavia一共一千多张图像,没到必须做K-Fold的程度,但训练集、验证集、测试集的划分直接决定两条技术路线结论的可信度。最简单也最稳妥的做法是train_test_split时加stratify参数,保证32个类别在三个集合里的比例一致。

from sklearn.model_selection import train_test_split train_val, test = train_test_split( df, test_size=0.15, random_state=42, stratify=df["label"]) train, val = train_test_split( train_val, test_size=0.15, random_state=42, stratify=train_val["label"])

random_state=42固定后,后续任何消融实验都不要改这个值,不然对比实验失去意义。test_size=0.15对Flavia这种总量不大的数据来说,意味着测试集约150张,平均每类四五张,波动会比较大。如果某类样本原本只有三十张,切完测试集只剩四张,预测结果很可能受单张图片的偶然因素左右。遇到这种情况,更稳妥的做法是改用分层K折交叉验证,至少跑5折,把每一折的平均值和方差都记录下来再下结论。

3. 传统机器学习路线:手工特征加集成分类器怎么稳定到90%

传统机器学习在Flavia上并不丢人。32类、每类几十张样本,手工特征配合SVM或随机森林,在精心预处理后完全可以跑到90%左右。比起CNN,这条路线训练快、可解释性强,特征含义清楚,适合在GPU不宽裕或只想快速验证数据特性的场景先跑一遍。后面做深度学习时,传统基线还可以用来判断数据划分有没有出问题。

3.1 特征工程:形状、纹理、颜色三类特征怎么搭配

叶片分类中形状特征最有用。Hu矩的7个值描述轮廓的整体几何分布,配合偏心率、实心度、矩形度这些衍生量,能得到一组扎实的形状描述。纹理特征里LBP对光照不敏感,适合刻画叶片脉结构;GLCM的对比度、能量等统计量也不错,但维度会膨胀。颜色特征放在HSV空间统计直方图,比RGB更符合人对叶片颜色的感知。三类特征拼接起来,维度一般在六十到一百之间,足够SVM和随机森林消化。

import numpy as np from skimage.feature import local_binary_pattern def extract_features(roi, mask): gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) gray = cv2.bitwise_and(gray, gray, mask=mask) moments = cv2.moments(mask) hu = cv2.HuMoments(moments).flatten() perim = cv2.arcLength( cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0][0], True) area = moments["m00"] solidity = float(area) / cv2.contourArea( cv2.convexHull(cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0][0])) lbp = local_binary_pattern(gray, 8, 1, method="uniform") hist_lbp, _ = np.histogram(lbp, bins=10, range=(0, 10)) hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV) h_hist = cv2.calcHist([hsv], [0], mask, [18], [0, 180]).flatten() h_hist = h_hist / (h_hist.sum() + 1e-6) return np.concatenate([hu, [perim, solidity], hist_lbp, h_hist])

代码里local_binary_pattern的(8, 1)表示8个邻域点、半径1像素,对叶片这种纹理尺度刚好;半径取太大会把叶脉细节抹掉,取太小又对噪声敏感。H通道用18个bin相当于每20度一个区间,能区分绿色叶片的不同色相,又不至于让特征维度太稀疏。拼接前每个特征分量尺度差异很大,所以后续一定要做标准化,SVM对这一点尤其敏感。

3.2 分类器选型:随机森林与SVM在Flavia上的差异

随机森林对特征尺度不敏感,几乎不用调参,训练也快,缺点是特征维度高时容易过拟合。线性SVM配合标准化特征,在几十维的小特征集上通常比RBF核更稳,因为RBF核在小样本上很容易把边界学得过于复杂。我一般两种都跑,各自做5折交叉验证,比较均值后才定最终模型。实际经验是两者准确率在Flavia上非常接近,随机森林的泛化略差一些,SVM的类别边界更干净。

from sklearn.ensemble import RandomForestClassifier from sklearn.svm import SVC from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline from sklearn.model_selection import cross_val_score X = np.array([extract_features(*load_roi(p)) for p in df["path"]]) y = df["label"].values rf = make_pipeline(StandardScaler(), RandomForestClassifier(n_estimators=300, random_state=42)) svm = make_pipeline(StandardScaler(), SVC(kernel="rbf", C=10, gamma="scale", random_state=42)) for name, clf in [("rf", rf), ("svm", svm)]: scores = cross_val_score(clf, X, y, cv=5, scoring="accuracy") print(f"{name}: {scores.mean():.3f} ± {scores.std():.3f}")

C=10配合gamma="scale"在标准化后的特征上通常是经验推荐值,不必迷信,跑完交叉验证后可以再试C=1、C=100做对比。n_estimators=300对Flavia这种规模已经足够,再加树的数量收益很小,只是拖慢训练。建议把5折的每一折准确率都打出来看,而不是只看均值——如果某一折明显偏低,多半是某个小类别的几张特殊图片被划分到了这一折里。

3.3 完整训练脚本:从特征矩阵到分类报告的发布版

把上述过程组装的可以一次跑完的脚本并不复杂,但有两个点要注意:特征矩阵提取在CPU上可能要跑几分钟,建议先缓存为features.npy,调参时直接读缓存;分类报告和混淆矩阵一定要落盘,方便后面写实验对比。

import numpy as np from sklearn.metrics import classification_report, confusion_matrix def main(): df = pd.read_csv("flavia_labels.csv") X = np.load("features.npy") if os.path.exists("features.npy") else build_and_save(df) X_train, X_test, y_train, y_test = split_by_label(df, X) pipe = make_pipeline(StandardScaler(), SVC(C=10, gamma="scale")) pipe.fit(X_train, y_train) pred = pipe.predict(X_test) print(classification_report(y_test, pred, target_names=species_list)) np.save("cm_traditional.npy", confusion_matrix(y_test, pred)) if __name__ == "__main__": main()

这里把build_and_save和split_by_label封装成函数,实际项目里你完全可以把它们写在同一个文件里,但原则是"一次提特征、多次训练"。特征缓存用npy格式就够了,不要存成pkl,npy谁都能读,兼容性更好。到这一步,你已经有了一个可靠的传统机器学习基线,接下来该上深度学习了。

4. 深度学习路线:用迁移学习把Flavia准确率推到95%以上

传统特征能做到90%,深度学习在同样的数据划分下通常能再往上走几个点,前提是你不是从零训练CNN。Flavia整个数据集只有一千多张图,从头训练一个深度网络,哪怕是最小的VGG11也会迅速过拟合。常见做法是使用ImageNet预训练的ResNet34或ResNet50,冻结部分层,微调最后几个block和分类头。

4.1 为什么ResNet34比自定义CNN更适合Flavia

自建CNN需要大量数据去学习底层边缘、纹理、颜色分布,Flavia养不起。ResNet34在ImageNet上学到的底层特征可以直接沿用,而且参数量比ResNet50小不少,对一千张级别的数据更不容易过拟合。ResNet50在训练集上可能冲得更高,但验证集上的提升往往不到1%,还附带更长的训练时间和更大的随机波动。对Flavia来说,ResNet34是性价比最高的backbone。

import torch import torch.nn as nn from torchvision import models def build_model(num_classes=32): model = models.resnet34(weights=models.ResNet34_Weights.IMAGENET1K_V1) for name, param in model.named_parameters(): if name.startswith("layer3") or name.startswith("layer4"): param.requires_grad = True else: param.requires_grad = False in_features = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(0.3), nn.Linear(in_features, num_classes) ) return model

requires_grad用name.startswith判断层的归属,比手动逐个写松散的layer_name.requires_grad要靠谱,也更方便改成解冻更多层的实验。我只放开layer3和layer4,因为这两个block靠近输出,学到的特征更接近具体任务;靠前的layer1和layer2保留通用特征,冻结不更新。分类头用Dropout(0.3)加Linear,0.3是折中值,ResNet50的话可以提到0.5。

4.2 数据增强与归一化:ImageNet的均值方差不能直接照抄

迁移学习里最常见的误区是把ImageNet的[0.485, 0.456, 0.406]和[0.229, 0.224, 0.225]原样用上。这套均值方差是ImageNet全量数据算出来的,Flavia的叶片图像颜色明显偏绿,归一化后某些通道动态范围会被压缩。实操上可以用训练集统计自己的均值和方差,或者至少做Normalize之前把去背景后的ROI像素分布打印出来看看。

from torchvision import transforms train_tf = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]) ]) val_tf = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]) ])

这里先用保守的0.5归一化,实际操作中建议在数据准备阶段把训练集所有像素的RGB均值方差算出来,存成npy替换这里的参数。RandomCrop(224)比直接Resize到224多一点平移不变性,但要求训练和验证的策略一致,否则推理时图像内容的分布和训练不一致,精度会莫名掉一截。ColorJitter的亮度对比度扰动要克制,叶片分类中颜色本身是有效特征,扰动太狠会抹掉类别差异。

4.3 微调策略与完整训练脚本

微调的核心在学习率:冻结层多时可用0.001的初始学习率,解冻了骨干网络的大部分层时应降到0.0001。我在这上面翻过两次车:一次解冻全部层还保持0.001,训练10个epoch后loss发散;另一次把BN层跟着微调,batch size从32换到16后验证集准确率跳了5个点。原因是BN层的统计量被小batch打乱,建议微调时固定BN的track_running_stats。

from torch.utils.data import DataLoader, Dataset from PIL import Image class FlaviaDataset(Dataset): def __init__(self, df, tf): self.df = df.reset_index(drop=True) self.tf = tf def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.loc[idx] img = Image.open(row["path"]).convert("RGB") return self.tf(img), row["label"] model = build_model().cuda() criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam( [p for p in model.parameters() if p.requires_grad], lr=1e-4) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=4, gamma=0.5) for epoch in range(15): model.train() for imgs, labels in train_loader: imgs, labels = imgs.cuda(), labels.cuda() optimizer.zero_grad() loss = criterion(model(imgs), labels) loss.backward() optimizer.step() scheduler.step() val_acc = evaluate(model, val_loader) print(f"epoch {epoch}: val_acc={val_acc:.3f}")

Adam加StepLR在Flavia这种小数据上非常省心,每4个epoch学习率减半,15个epoch基本收敛。如果验证集精度还在上升,可以续跑几个epoch;如果峰值在epoch 8附近就出现,后续波动变大,说明模型开始过拟合。模型保存时用state_dict加class_to_idx一起存,避免换机器加载时类别顺序对不上。每个epoch结束时算一次验证集准确率是最低要求,建议同时存一份当前最佳模型,训练完毕直接取最佳权重。

提示:如果训练过程中显存占用不多但GPU利用率始终上不去,问题往往在num_workers设得太低,数据加载阻塞了训练循环,这不是模型或超参的问题。

5. 避坑指南:Flavia叶片分类里的五个高频翻车点

这一章不讲原理,只列出我在Flavia和类似植物叶片项目里实际遇到过的五类问题。每一条都按"现象 → 原因 → 解决"的顺序写,排查时可以对着看。

5.1 现象:训练集准确率99%但测试集只有55%

这是典型的标签泄漏或数据划分不当。检查点有三个:训练集和测试集是否有同源图片重复;预处理是否用了测试集统计量(例如全量数据算归一化均值);随机种子是否在划分后被改过。Flavia里某些类别的叶片来自同一棵树的连拍序列,外观极其相似,如果被同时分进训练集和测试集,模型等于提前看到了题目。解决方法是按来源分组划分,至少保证同一二级目录下的图片不要跨集合放。没有个体编号的Flavia版本,尽量采用分层K折而不是简单随机划分。

5.2 现象:叶片边缘被裁掉,形状特征全乱

背景分离阈值没调好就会裁掉叶片边缘的浅色部分,或把叶柄阴影当前景。解决办法是做完ROI提取后,随机挑二十张看原图和掩膜叠加图,而不是只看输出尺寸。另一个常见问题是原图里有多个轮廓,最大轮廓不一定是叶片,可能是背景里的杂物或影子。这时需要按轮廓面积和矩形度做一层过滤,面积阈值建议设为整张图面积的1%以上,低于这个值的一律视为噪声。

5.3 现象:同一份代码在Windows上跑出乱码路径

Flavia图片文件名一般是纯数字,但解压工具可能导致中文目录名变成乱码。脚本里出现UnicodeDecodeError或FileNotFoundError时不要急着改编码,先用os.listdir打印原始字节看看。我处理这类问题的办法是统一重命名一次:所有图片按class_序号.jpg格式复制到干净目录,之后所有路径处理都基于新目录,不碰原始路径。这一步可以写进项目说明里,别人复现时能省下大量时间。

5.4 现象:训练曲线收敛但模型对同类图片得分差异极大

和图像尺度不一致有关。训练时用RandomCrop、验证时用CenterCrop本身没问题,但若训练前没做Resize,叶片在图像里占的比例不一样,模型会把尺度当成分类线索。Flavia同一物种拍摄距离不同,叶片在画面里的大小差异明显。解决方法是统一先Resize到固定尺寸再做裁剪和增强,确保所有叶片的尺度分布一致。可以在DataLoader里打印第一个batch的像素均值确认。

5.5 现象:GPU显存没满但训练慢得离谱

多半是数据加载卡在IO上。Dataset.__getitem__每次读原图、做ROI提取、再PIL转换,CPU来不及喂数据。Flavia原图若是一千六乘一千二的分辨率,预处理开销不小。建议在预处理阶段统一把ROI缩放后缓存成png或npy,训练时直接读缓存,num_workers至少设到4。很多"训练慢"的抱怨,最后都是数据管线没打通,不是GPU算力不够。

6. 验证与进阶:混淆矩阵、Grad-CAM和ONNX导出

模型训练完,别急着看总精度。先把测试集混淆矩阵打出来,找到互分错误最多的两对类别,翻原图看它们形态是否接近;如果接近,那是任务本身难度,不必深究代码。接着用Grad-CAM看模型的注意力区域,叶片场景中好的注意力应当落在叶脉和轮廓附近。最后若要做部署,把模型导出为ONNX,再用onnxruntime跑一遍验证集,确认推理精度和PyTorch一致后再上线。这三步分别回答"错在哪、为什么对、怎么落地"。

6.1 混淆矩阵:定位互分错误最多的类别对

from sklearn.metrics import ConfusionMatrixDisplay, confusion_matrix cm = confusion_matrix(y_test, pred) disp = ConfusionMatrixDisplay(cm, display_labels=species_list) disp.plot(cmap="Blues", xticks_rotation=90) plt.savefig("confusion_matrix.png")

cm[i, j]表示真实类别i被预测成j的数量。打印top-3错误对之后,回去翻原图,观察两个类别的叶片在裂片深度、叶基形状上的差异,这些视觉线索往往就是特征工程和CNN都没利用好的部分。

6.2 Grad-CAM:看模型到底在看叶片的哪部分

def grad_cam(model, x, target_layer): acts = {} h = target_layer.register_forward_hook( lambda m, i, o: acts.__setitem__("a", o)) out = model(x.unsqueeze(0).cuda()) idx = out.argmax(dim=1) grad = torch.autograd.grad(out[0, idx], acts["a"])[0] weights = grad.mean(dim=(2, 3), keepdim=True) cam = torch.relu((weights * acts["a"]).sum(dim=1, keepdim=True)) cam = torch.nn.functional.interpolate( cam, size=(224, 224), mode="bilinear", align_corners=False) h.remove() return cam.squeeze().cpu().numpy()

传入model.layer4[-1]作为target_layer即可。如果报错说梯度不经过某个张量,检查输入x是否被to(device)之后仍保持requires_grad为可求导状态,常见的写法是在forward前对输入调用一次独立的clone()避免共享内存干扰。

6.3 ONNX导出与一致性验证

model.eval() dummy = torch.randn(1, 3, 224, 224).cuda() torch.onnx.export(model, dummy, "flavia_resnet34.onnx", input_names=["input"], output_names=["output"], opset_version=12)

导出后必须用onnxruntime加载并在完整验证集上复测一轮精度。我遇到过导出前后精度差出0.5%的情况,最后定位到是model.eval()没调用,Dropout被固化进了计算图。这类事不值得踩第二次,导出前把模型状态检查清楚,比事后对精度差头疼要省事。我自己的习惯是任何Flavia规模的小样本分类项目,永远先建立传统基线,确认数据管线没有泄漏后再上迁移学习,否则所有精度数字都不知道该信谁。希望这些经验能帮到你。

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

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

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

立即咨询