资讯详情 Flavia叶片分类实战:从传统特征工程到ResNet迁移学习的完整避坑指南
📅 2026/10/6 21:03:04
简介这套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_thresh220): 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, keycv2.contourArea) x, y, w, h cv2.boundingRect(c) roi img[y:yh, x:xw] mask binary[y:yh, x:xw] return roi, maskgray_thresh220是个经验值对多数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, indexFalse)先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_size0.15, random_state42, stratifydf[label]) train, val train_test_split( train_val, test_size0.15, random_state42, stratifytrain_val[label])random_state42固定后后续任何消融实验都不要改这个值不然对比实验失去意义。test_size0.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, maskmask) 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, methoduniform) hist_lbp, _ np.histogram(lbp, bins10, 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_estimators300, random_state42)) svm make_pipeline(StandardScaler(), SVC(kernelrbf, C10, gammascale, random_state42)) for name, clf in [(rf, rf), (svm, svm)]: scores cross_val_score(clf, X, y, cv5, scoringaccuracy) print(f{name}: {scores.mean():.3f} ± {scores.std():.3f})C10配合gammascale在标准化后的特征上通常是经验推荐值不必迷信跑完交叉验证后可以再试C1、C100做对比。n_estimators300对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(C10, gammascale)) pipe.fit(X_train, y_train) pred pipe.predict(X_test) print(classification_report(y_test, pred, target_namesspecies_list)) np.save(cm_traditional.npy, confusion_matrix(y_test, pred)) if __name__ __main__: main()这里把build_and_save和split_by_label封装成函数实际项目里你完全可以把它们写在同一个文件里但原则是一次提特征、多次训练。特征缓存用npy格式就够了不要存成pklnpy谁都能读兼容性更好。到这一步你已经有了一个可靠的传统机器学习基线接下来该上深度学习了。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_classes32): model models.resnet34(weightsmodels.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 modelrequires_grad用name.startswith判断层的归属比手动逐个写松散的layer_name.requires_grad要靠谱也更方便改成解冻更多层的实验。我只放开layer3和layer4因为这两个block靠近输出学到的特征更接近具体任务靠前的layer1和layer2保留通用特征冻结不更新。分类头用Dropout(0.3)加Linear0.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(p0.5), transforms.ColorJitter(brightness0.2, contrast0.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(dropTrue) 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], lr1e-4) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size4, gamma0.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(fepoch {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_labelsspecies_list) disp.plot(cmapBlues, xticks_rotation90) 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(dim1) grad torch.autograd.grad(out[0, idx], acts[a])[0] weights grad.mean(dim(2, 3), keepdimTrue) cam torch.relu((weights * acts[a]).sum(dim1, keepdimTrue)) cam torch.nn.functional.interpolate( cam, size(224, 224), modebilinear, align_cornersFalse) 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_version12)导出后必须用onnxruntime加载并在完整验证集上复测一轮精度。我遇到过导出前后精度差出0.5%的情况最后定位到是model.eval()没调用Dropout被固化进了计算图。这类事不值得踩第二次导出前把模型状态检查清楚比事后对精度差头疼要省事。我自己的习惯是任何Flavia规模的小样本分类项目永远先建立传统基线确认数据管线没有泄漏后再上迁移学习否则所有精度数字都不知道该信谁。希望这些经验能帮到你。本文还有配套的精品资源点击获取