ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

野生可食用植物62分类实战:细粒度图像识别与迁移学习全流程

野生可食用植物62分类实战:细粒度图像识别与迁移学习全流程 简介面向深度学习与图像分类实践任务提供一套野生可食用植物图像分类数据集覆盖六十二类常见野外植物每类包含五十至一百多张图像训练集四千三百五十五张、测试集三百一十张合计四千六百六十五张。数据已经按训练与测试分类存放data-train和data-test下各子文件夹以类别名命名同时还提供类别字典json文件与可视化脚本可快速完成标签映射与样本预览未做额外处理即可直接接入模型训练流程。包内共两千个文件主体为一千九百九十八张jpg图像另含一个类别字典与一个可视化脚本压缩包约二百九十二点八九MB解压后目录结构清晰加载使用轻量。目前已有二百三十六人浏览学习适合图像分类初学者、算法研究者以及植物识别项目开发者能够显著减少数据采集与整理时间便于开展迁移学习、数据增强验证和端到端分类实验。1. 野生可食用植物62分类数据集真正的难点不在“分类”而在“可食用”做深度学习图像分类的人如果手里拿到一个62类的野生可食用植物数据集第一反应通常是这不就是一个标准的多分类任务吗找个预训练模型微调一下就行。但真正打开数据看一遍就会发现这个任务比花卉分类、CIFAR-100 要麻烦得多——同一株植物在不同生长期、不同光线、不同拍摄角度下叶形和颜色差异极大而荠菜与独行菜、水芹与毒芹这类近似种连有经验的人肉眼都容易看错。整个任务的核心矛盾不再是“怎么把一堆陌生类目分对”而是在野外复杂背景下把可食用与不可食用的边界真正学出来。这篇文章按做这类数据集时比较成熟的那套流程走一遍从看类别分布、清洗错误标注到决定增强策略、选 backbone、调 epoch最后用混淆矩阵找易错对每一步给出可直接跑的代码和参数。2. 先摸清62分类数据集的类别体系、标签分布与划分边界2.1 类别体系不等于植物学分类图像标签要按“目视可区分度”设计野生可食用植物数据集的类别标签通常来自植物志或野外手册但这和机器学习任务里的 label 是两回事。植物学分类按科、属、种层层划分同一属下的物种在图像上可能高度相似比如伞形科里的水芹和毒芹叶片和花序结构只有细枝末节的差别。图像分类算法学的是像素到类别的映射如果两个类别在常见拍摄角度下几乎无法区分再多的数据也难让模型稳定收敛。拿到数据集后先做一件事把62个类别逐个看一遍样本图按“这张图能不能让一个普通人看出区别”的标准重新校准类别。我之前处理类似植物数据集时发现有些类别是按“食用部位”拆的比如同一种植物分为“嫩叶期”和“果实期”这两者视觉差异很大但标签语义重叠容易让模型学出错误的共性。如果标题里说的62分类已经排好了类目训练前至少把每个类别的样本数和代表图列出来给每个类别写一个短备注比如“荠菜叶缘锯齿明显基生莲座状”“毒芹茎上有紫红色斑点”。这份备注在后续找易错对时会非常有用。2.2 用脚本统计类别分布优先发现长尾类别和空类别模型训练前第一个要跑的不是训练脚本而是分布统计脚本。一个62分类的植物数据集类别分布几乎不可能均匀采集季节和地域限制会让某些类别只有几十张图另一些类别有几千张。长尾分布下如果直接使用普通的 CrossEntropyLoss模型会牺牲少数类来压低多数类的 loss。from pathlib import Path from collections import Counter data_dir Path(wild_edible_62) train_dir data_dir / train class_counts Counter() total 0 for img_path in train_dir.rglob(*.jpg): class_counts[img_path.parent.name] 1 total 1 print(f总样本数: {total}, 类别数: {len(class_counts)}) for label, cnt in class_counts.most_common(10): print(f{label}: {cnt}) print(...) for label, cnt in class_counts.most_common()[-10:]: print(f{label}: {cnt})这段代码用 Path.rglob 递归寻找 train 目录下所有 jpg 文件img_path.parent.name取的是上一级目录名也就是类别名。运行之后重点看两类信息最低的5个类别分别有多少张有没有类别少于30张最高的5个类别是否某个类别超过总数的30%。单类超过30%就要考虑采样策略少于30张的类别在后面的增强阶段要特殊处理或者收集更多数据否则这个类别的验证准确率会几乎没有参考意义。2.3 按类别分层划分训练集和验证集避免验证集失真植物数据集的划分不能直接random_split。同一个采集点、同一时间拍的一组照片背景高度相似如果这些图同时出现在训练集和验证集里验证指标会虚高。常见做法是先按类别做分层划分保证每个类别在训练集和验证集中的比例一致再把数据按目录组织成 ImageFolder 结构。import shutil, random from pathlib import Path from sklearn.model_selection import train_test_split random.seed(42) output_dir Path(wild_edible_62_split) output_dir.mkdir() split_map {train: train, val: val} for subset in split_map: (output_dir / subset).mkdir() all_images list(Path(wild_edible_62_raw).rglob(*.jpg)) labels [str(img.parent.name) for img in all_images] train_paths, val_paths train_test_split( all_images, test_size0.2, stratifylabels, random_state42 ) for subset, paths in [(train, train_paths), (val, val_paths)]: for img_path in paths: target_dir output_dir / subset / img_path.parent.name target_dir.mkdir(parentsTrue, exist_okTrue) dst target_dir / img_path.name shutil.copy2(str(img_path), str(dst)) print(fcopied {img_path} - {dst})这段代码用 sklearn 的train_test_splitstratifylabels会让每个类别在划分后的比例与原分布一致random_state42固定随机数种子。按类别建子目录后后续直接用torchvision.datasets.ImageFolder加载省去自己写 Dataset 的工作。注意这里用的是shutil.copy2会保留元数据但会多占一份磁盘空间如果原数据能接受被移动可以换成shutil.move。3. 清洗错误标注、设定预处理参数增强策略要围绕细粒度特征设计3.1 用预训练模型做一次“损失值清洗”找出标错的图野生植物数据集最大的隐患不在模糊而在标注错误——采集者认错了种或者把两张不同植物的图放进了同一个类别。这类错误如果留在训练集里会直接拉低模型对相关两个类别的区分能力。清洗思路很简单先用一个 ImageNet 预训练模型对全部训练图做提取特征和分类把模型预测置信度与真实标签不一致的样本全部列出来人工复核。import torch import torch.nn as nn import timm from torchvision import transforms, datasets transform transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) train_data datasets.ImageFolder(wild_edible_62_split/train, transformtransform) loader torch.utils.data.DataLoader(train_data, batch_size64, num_workers4) model timm.create_model(resnet50, pretrainedTrue) model.eval().cuda() model.reset_classifier(0) # 去掉分类头只拿特征注意这里用reset_classifier(0)去掉分类头后模型输出的是特征向量而不是类别预测。严格来说要做“错误标注清洗”需要的是带分类头的模型预测更好的做法是直接用一个在类似植物图像上微调过的模型或者用 ImageNet 预训练模型的分类结果做初筛。具体操作上加载带 1000 类的原始resnet50把输入图片预测出的 top-1 类别和真实标签对比如果模型预测得很有把握但和标签不符就说明标注很可能有问题。这一步建议在训练集上跑把置信度高但预测错误的图片导出到一个单独文件夹逐张人工确认。3.2 输入尺寸定在224还是384取决于你观察到的叶片纹理细节植物图像的细粒度特征集中在叶脉纹理、叶缘锯齿、绒毛和茎部斑点这类信息对分辨率很敏感。224×224 是绝大多数预训练模型的默认输入尺寸迁移学习时用默认值省事。但如果你把样本放大看发现同属近似种的区别只有叶缘锯齿密度这种尺度建议把训练尺寸提到 384 或 448。代价是显存占用约增加2倍训练时间拉长但换来的通常是 2到4个百分点。基础变换代码建议这样写train_transform transforms.Compose([ transforms.Resize((384, 384)), transforms.RandomResizedCrop(384, scale(0.7, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(15), transforms.ColorJitter(brightness0.15, contrast0.15, saturation0.15, hue0.05), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])RandomResizedCrop的scale控制裁剪面积占原图的比例植物数据集不建议降到 0.3 以下因为过度缩小会让叶片纹理信息丢失。colorJitter的hue0.05很关键色相偏移太大会改变植物的天然颜色而颜色恰好是可食用植物辨认的重要特征比如毒芹茎上的紫红色斑点和可食用水芹的绿色茎秆色相偏移一旦超过某个范围这类线索就被破坏了。3.3 增强强度要克制细粒度分类不是增强越多越好很多人在通用图像分类任务里养成了“增强越狠越防过拟合”的习惯但到了植物细粒度分类上这个经验会反噬。植物图像分类模型依赖的判别特征包括叶形、颜色、纹理而过度的随机裁剪、大角度旋转、极端亮度变化会让模型学到的特征偏离真实分布。野外场景下本来拍摄角度就千变万化训练时再人为制造大量极端视角反而让模型把噪声当特征。下面这张表是我处理类似数据集时常用的一套增强参数按“作用维度”分类适合微调使用增强操作推荐参数作用注意事项RandomResizedCropscale(0.7, 1.0)模拟不同拍摄距离小于0.5会丢失叶脉纹理RandomHorizontalFlipp0.5左右翻转不改变植物物种上下翻转请勿开启RandomRotation15度以内模拟手持拍摄倾斜超过30度会切出背景噪声ColorJitterbrightness/contrast0.15适应不同天气光照hue不要超过0.05CutMix / MixUpalpha0.2缓解长尾类别过拟合62分类下建议先跑通基准再启用CutMix 和 MixUp 这类区域混合增强对细粒度分类是有争议的混合样本会让模型混淆叶片边缘特征但在类别数多、样本不均衡时又确实能提升泛化。我的建议是先跑一个不加混合增强的 baseline确认准确率稳定之后再加 CutMix 对比如果提升不明显就直接关掉。别把增强当作免费的午餐凡事以两次实验的差距为准。4. 选预训练模型、组合优化器与学习率把 epoch 用在刀刃上4.1 backbone 怎么选样本量、细粒度、显存三个约束取交集62分类的植物数据集属于中等类别规模的中型数据集样本量通常在几千到几万张之间。这个量级用不着从头训练必然走迁移学习。backbone 的选择逻辑可以套用三条主线模型参数量输入尺寸适合场景短板ResNet5025.6M224/384数据量小、显存有限、快速迭代细粒度特征提取能力一般EfficientNet-B419M384样本中等、追求精度/计算比训练耗时略长ConvNeXt-Base88M224/384精度优先、显存充足微调时间成本高ViT-S/1622M384数据量足够、验证集稳定小数据集收敛慢如果你手上的训练集只有两三千张ResNet50 或 EfficientNet-B4 是常态选择如果每类平均有几百张图、总量上万ConvNeXt 这类模型能明显拉开与 ResNet 的差距。对初学者来说timm库替换模型只需改一个字符串参数多试几个 backbone 的成本比想象中低。4.2 一个可直接修改的62分类训练脚本下面这个脚本覆盖了数据加载、模型初始化、训练循环、验证和 checkpoint 保存直接改路径就能跑import torch import torch.nn as nn import timm from torchvision import transforms, datasets from torch.utils.data import DataLoader from torch.cuda.amp import GradScaler, autocast num_classes 62 epochs 30 batch_size 32 lr 1e-4 model_name efficientnet_b4 train_tf transforms.Compose([ transforms.Resize((384, 384)), transforms.RandomResizedCrop(384, scale(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) val_tf transforms.Compose([ transforms.Resize((384, 384)), transforms.CenterCrop(384), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) train_ds datasets.ImageFolder(wild_edible_62_split/train, train_tf) val_ds datasets.ImageFolder(wild_edible_62_split/val, val_tf) train_loader DataLoader(train_ds, batch_sizebatch_size, shuffleTrue, num_workers6, pin_memoryTrue) val_loader DataLoader(val_ds, batch_sizebatch_size, shuffleFalse, num_workers6) model timm.create_model(model_name, pretrainedTrue, num_classesnum_classes).cuda() criterion nn.CrossEntropyLoss(label_smoothing0.1) optimizer torch.optim.AdamW(model.parameters(), lrlr, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxepochs) scaler GradScaler() for epoch in range(epochs): model.train() running_loss 0.0 for images, labels in train_loader: images, labels images.cuda(), labels.cuda() optimizer.zero_grad() with autocast(): outputs model(images) loss criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() running_loss loss.item() * images.size(0) model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.cuda(), labels.cuda() outputs model(images) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) val_acc correct / total print(fEpoch {epoch1}/{epochs} | loss {running_loss/total:.4f} | val_acc {val_acc:.4f}) torch.save(model.state_dict(), fcheckpoint_{model_name}_epoch{epoch1}.pth) scheduler.step()代码里做了几个关键选择逐一说清楚。label_smoothing0.1对细粒度分类很有效果它不让模型对训练标签过度自信能提升对近似种的泛化。优化器用的是 AdamW 而不是 SGD因为迁移学习场景下 AdamW 对学习率的敏感度低收敛更稳定weight decay 设到了 1e-4。混合精度的autocast和GradScaler配套出现384 输入下显存能节省大概40%。CosineAnnealing 的学习率曲线在30个 epoch 内从初始值平滑降到0适合这种中等规模的微调任务。4.3 验证集准确率之外还要看什么loss 曲线和训练/验证差距训练时最容易犯的错误是只盯验证集准确率跑到90%就收工。但实际上90% 的验证准确率背后可能已经藏着严重的过拟合。建议每个 epoch 同时记录训练集 loss 和验证集 loss两者差距持续拉大说明模型开始死记硬背训练数据。植物数据集的背景、光照差异大模型很容易“记住”背景纹理而不是植物本身。一个比较稳的经验是验证准确率连续3个 epoch 不再上升时先别急着加数据增强回头去检查是不是学习率下降太快或者该换更强的 backbone。对 epoch 数来说30个 epoch 对这个规模的数据集是下限如果用的是 ViT可能要50到60个 epoch 才能看到收敛。训练过程中把每个 epoch 的验证准确率打印保留下来和类别分布统计放一起比对能更早发现长尾类别是否拖累了整体准确率。5. 训练完成后用混淆矩阵揪出“毒芹 vs 水芹”再用类别层级做安全校验5.1 混淆矩阵定位高频错分类别对训练结束后用验证集生成混淆矩阵找出错分次数最多的类别对。这一步比整体准确率重要得多因为它直接告诉你模型在哪些真实场景下会给出危险判断。import numpy as np import torch from sklearn.metrics import confusion_matrix, classification_report # 假设 val_loader 已经在前面定义model 已加载最优权重 model.eval() all_labels, all_preds [], [] with torch.no_grad(): for images, labels in val_loader: images images.cuda() outputs model(images) preds outputs.argmax(dim1).cpu().numpy() all_labels.extend(labels.numpy()) all_preds.extend(preds) classes train_ds.classes cm confusion_matrix(all_labels, all_preds) wrong_pairs [] for i in range(len(classes)): for j in range(len(classes)): if i ! j and cm[i][j] 1: wrong_pairs.append((cm[i][j], classes[i], classes[j])) wrong_pairs.sort(reverseTrue) for count, true_label, pred_label in wrong_pairs[:10]: print(f真实类别 {true_label} - 被预测为 {pred_label}共 {count} 张)这段代码遍历混淆矩阵的非对角线元素把真实类别被预测成其他类别的次数排序输出。如果“水芹”经常被预测成“毒芹”说明模型并没有学到茎部斑点和叶鞘这几个关键判别特征下一步该去扩充这两个类别的训练样本或单独做二分类微调。classification_report里每个类别的 precision 和 recall 也要重点看recall 低的类别就是被漏检最多的类别。5.2 用 t-SNE 看特征空间的“危险区域”重叠情况混淆矩阵告诉你是哪两个类容易混t-SNE 能告诉你原因。取模型最后一个全连接层之前的特征向量对验证集全部样本做降维可视化from sklearn.manifold import TSNE # 提取特征model.reset_classifier(0) 后重新 forward 得到特征 feature_list, label_list [], [] with torch.no_grad(): for images, labels in val_loader: features model.forward_features(images.cuda()) features features.mean(dim[2, 3]) # 全局平均池化 feature_list.append(features.cpu()) label_list.extend(labels.numpy()) feats torch.cat(feature_list).numpy() tsne TSNE(n_components2, perplexity30, random_state42) emb tsne.fit_transform(feats) for idx, label in enumerate(label_list): color palette[label] plt.scatter(emb[idx, 0], emb[idx, 1], colorcolor, s5)forward_features拿到的是 CNN 最后一个 stage 的特征图均值池化成向量后降维。图上如果两个易错类别交织在一起说明模型不是“差一点点就能分开”而是根本没学到有效的区分特征这比单纯提升 epoch 数更值得关注。泛化误差分析里常说模型复杂度与特征可分性之间的关系在植物细粒度任务上最直观的体现就在这张图上。5.3 一个落地技巧把输出从62个类别改成“类别 可食用状态”两级结构最后这个技巧值得在正式应用时用上与其让模型直接输出62类中的一个不如在模型推断后加一层规则校验。把62个类别按“可安全食用、需处理食用、不可食用”映射到一个状态表模型预测出类别后查表得到风险等级。只有当模型的 top-2 置信度接近时才需要用户人工确认。这一步不改变模型结构只改推断逻辑但对实际使用体验的提升非常明显。如果把状态表写成 JSON 放进工程里每次模型更新都不需要改代码。edibility_map { 荠菜: 安全, 蒲公英: 安全, 毒芹: 危险, 白屈菜: 危险, 马齿苋: 安全, 龙葵: 需处理, # ... 其余 62 类按植物志映射 } def predict_with_risk(model, image_tensor, top_k2): with torch.no_grad(): prob torch.softmax(model(image_tensor), dim1) top_probs, top_indices torch.topk(prob, min(top_k, prob.size(1))) results [] for p, idx in zip(top_probs[0], top_indices[0]): class_name classes[idx.item()] results.append({ class: class_name, probability: round(p.item(), 3), edibility: edibility_map.get(class_name, 未知) }) return results这里top_k2是刻意为之当 top-1 类别没有绝对优势时把最可能的两个候选连同可食用状态一起返回由上游业务决定是放行还是转人工。对野生可食用植物识别这类错误代价极高的场景51% 的置信度根本不够用保留不确定性比强行给一个答案安全得多。本文还有配套的精品资源点击获取
返回列表