尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

中草药叶片识别分类实战:从数据集构建到PyTorch训练全流程

中草药叶片识别分类实战:从数据集构建到PyTorch训练全流程 简介这份数据包面向计算机视觉与深度学习入门及进阶学习者聚焦中草药叶片图像识别分类场景可用作图像分类模型训练与评估的数据基础。包内数据已按训练集与验证集划分其中train共4800张图片、val共1400张图片并附带80个类别的名称字典文件可直接适配yolov5等主流分类任务的训练流程。压缩包共2000个文件以1998张jpg图像为主另含1个Python可视化脚本和1个JSON格式类别字典文件整体体积约175MB目录结构简明便于快速理解和使用。目前已有119人学习浏览。该资源的一大亮点是提供了数据可视化脚本可随机抽取4张图片进行展示并保存至当前目录方便使用者直观检查数据质量与类别分布。对于正在准备中草药识别项目、需要现成分好类的图像数据集或希望快速搭建分类基线实验的开发者来说这套资料能节省大量人工整理和标注时间是一个实用且可直接上手的起步资源。1. 中草药叶片识别分类先从这份划分好的数据说起中草药叶片识别分类是计算机视觉里典型的细粒度分类任务难不在模型而在数据。公开植物数据集多为花园植物或杂草类别对不上标注也乱。这份资源把中草药叶片数据按 train/val/test 划分好附类别字典文件和数据可视化脚本拿到手就能直接跑分类训练。它解决三件事不用自己爬图整理标注、不用猜类别编号、不用临时写脚本看分布。适合正在做计算机视觉大作业、课程设计或想跑通完整分类流程的人。我拆的时候最关心两件事划分是否可靠、类别字典能否对上文件夹名这决定后面所有工作的基础。下面从目录结构讲起。2. 读懂资源结构目录组织与类别字典文件2.1 目录结构train/val/test 的划分逻辑解压之后标准布局长这样herb_leaf_data/ ├── train/ │ ├── 001_甘草/ │ │ ├── 001_001.jpg │ │ ├── 001_002.jpg │ │ └── ... │ ├── 002_金银花/ │ ├── 003_薄荷/ │ └── ... ├── val/ │ ├── 001_甘草/ │ └── ... ├── test/ │ ├── 001_甘草/ │ └── ... └── class_dict.json每个类别一个文件夹文件夹名是序号_中文名里面是该类别所有叶片图片。这种组织方式是深度学习框架最认的一种PyTorch 的 torchvision.datasets.ImageFolder 可以直接读Keras 的 image_dataset_from_directory 也能直接消费不用写任何自定义解析逻辑。对做课程设计或者想快速跑通流程的人来说这个结构省掉的其实是数据整理这个最枯燥的环节。为什么要按 train/val/test 三分因为中草药叶片这类细粒度分类任务里同一种叶片在不同生长阶段、不同光照下拍出来差异很大。如果只做 train/test 二分调超参的时候你就得反复拿测试集去试试多了测试集就泄漏了最终报出来的准确率虚高答辩的时候一追问就露馅。val 集就是给调参和早停用的test 集只能最后碰一次。这个划分比例常见做法是 7:2:1 或者 6:2:2这份资源里按目录数量粗看大概是 7:2:1 的量级train 占了多数val 和 test 都够用于验证和最终评估。有个细节值得注意train、val、test 三层的类别文件夹名完全一致都是序号_中文名。这意味着你在训练代码里得到的类别索引在 val 和 test 上可以直接复用不需要重新构建映射。如果你的数据集是 train 用序号、val 用中文名这种混搭那评估的时候就得写额外的对齐逻辑非常容易出 bug而且这种 bug 不报错只会让指标悄悄变差。2.2 类别字典文件标签 ID 到中文名的映射class_dict.json 是这份资源里最容易被忽略、但实际价值最高的文件。它的结构很简单{ 0: 甘草, 1: 金银花, 2: 薄荷, 3: 枸杞叶, 4: 菊花, 5: 鱼腥草 }键是类别 ID值是对应的中草药中文名。注意这个 ID 的排序规则它和文件夹名的序号是对齐的按文件夹名排序后依次编号。换句话说train/001_甘草 里的图片全部是类别 ID 0train/002_金银花 里的图片全部是类别 ID 1。这个对应关系是整个资源能直接跑起来的关键。这里有个容易翻车的点如果你直接用 ImageFolder 加载它内部会给每个文件夹按字母序重新编号编号结果和 class_dict.json 里手写的 ID 未必一致。比如文件夹名如果出现了 010_xxx 和 02_xxx 这种字符串排序会先把 010 排到 02 前面ImageFolder 的 class_to_idx 就和字典文件对不上了。想验证是否对齐一行代码搞定import json from torchvision import datasets train_ds datasets.ImageFolder(herb_leaf_data/train) with open(herb_leaf_data/class_dict.json, r, encodingutf-8) as f: class_dict json.load(f) # 按索引值排序还原 ImageFolder 眼中的类别顺序 imagefolder_order [name for name, _ in sorted(train_ds.class_to_idx.items(), keylambda x: x[1])] # 按 ID 从小到大还原字典文件里的中文名顺序 dict_order [class_dict[str(i)] for i in range(len(class_dict))] print(ImageFolder 顺序:, imagefolder_order) print(class_dict 顺序:, dict_order) print(一致:, imagefolder_order dict_order)这段代码的逻辑先把 ImageFolder 内部生成的类名-索引映射按索引值排序还原出它眼中的类别顺序再把 class_dict.json 按 ID 从小到大还原出中文名列表最后逐位比对。如果不一致说明你加载时不能直接信 ImageFolder 自带的索引必须显式传入 class_dict 做映射。我一般会在数据模块里直接加 assert不一致就中断省得后面排查半天。从实际使用角度看类别字典还有个用途训练结束后的可视化。模型输出的预测是一个整数你怎么知道是哪种草药没有字典文件你就得去翻文件夹名有字典文件直接查表即可import json with open(herb_leaf_data/class_dict.json, r, encodingutf-8) as f: class_dict json.load(f) pred_idx 3 print(预测类别:, class_dict[str(pred_idx)])类别字典在很多课程设计里被当成摆设实际上它是整个数据管线里最后一步的关键。你训练跑完、指标都挺好结果预测的时候吐出来一个数字没有字典文件就等于没做。这也是我拆这份资源时坚持要先讲它的原因。2.3 与常见数据集格式的差异顺手对比一下帮判断这份资源适不适合你现在的环境特性本资源常见 ImageNet 风格常见 VOC 风格目录结构按类别分文件夹按类别分文件夹单目录XML标注标签形式文件夹名JSON字典文件夹名XML 标注框是否适合分类直接可用直接可用需要先转格式是否适合检测需要先转格式需要先转格式直接可用如果你的目标是纯分类模型这个资源开箱即用如果你想做目标检测框出叶片位置那得先把图片整理成检测格式后面再说。另外注意图片文件名是序号_图片编号.jpg的形式这个命名在文件排序时是稳定的如果你要做按时间或按拍摄条件的子集划分命名规则也可以直接复用。3. 可视化脚本实操三张图摸清数据分布3.1 脚本运行方式与输出内容资源里带了一个 Python 数据可视化脚本我把它当数据体检工具用。典型运行方式python visualize_data.py --data_dir ./herb_leaf_data --save_dir ./output脚本会扫描 data_dir 下所有子目录统计每个类别的样本数生成三样东西类别分布直方图、样本预览网格图、统计摘要文本。输出文件默认存到 save_dir文件名类似 class_distribution.png、sample_preview.png、stats.txt。如果你的环境没有装 matplotlib先装一下pip install matplotlib pillow参数说明--data_dir 指向解压后的数据根目录脚本会自动遍历 train/val/test 三个子集--save_dir 是输出目录不存在会自动创建。有的版本还支持 --topk 参数只显示样本数最多的前 K 个类别方便在类别很多的时候看图。对刚入门 Python 的人来说这个脚本本身就是很好的参考代码读一遍能学到 os 遍历、Counter 统计、matplotlib 出图这些高频操作。3.2 类别分布直方图识别不平衡的第一道关口脚本核心的统计逻辑长这样import os from collections import Counter import matplotlib.pyplot as plt def count_samples_per_class(root): counter Counter() for split in [train, val, test]: split_path os.path.join(root, split) if not os.path.isdir(split_path): continue for cls_name in os.listdir(split_path): cls_path os.path.join(split_path, cls_name) if os.path.isdir(cls_path): n len([f for f in os.listdir(cls_path) if f.lower().endswith((.jpg, .jpeg, .png))]) counter[cls_name] n return counter counts count_samples_per_class(herb_leaf_data)这段代码的逻辑遍历 train/val/test 三个子集对每个子集下的每个类别文件夹统计图片数量累加到一个 Counter 里。文件后缀过滤用的是 .jpg/.jpeg/.png这是为了避免把隐藏文件或者 .txt 说明文件当成图片。注意统计的是三个子集的总和如果要分别看某个子集的分布把循环改成只传对应 split 路径即可。为什么先统计分布因为中草药叶片数据集天然存在类别不平衡。有的药材常见、好采集能拍几百张有的药材季节性很强只能凑三五十张。不平衡超过 1:10 的时候你的模型学出来的很可能全是多数类更准、少数类基本瞎猜的状态整体准确率还挺好看但一查混淆矩阵就露馅。直方图怎么读先看有没有类别样本数低于 50 的——这类别的模型表现会明显差一截再看最大最小类别比值超过 10 倍就要考虑加权采样或者类别权重。我一般习惯在训练前把这张图打出来存到笔记里后面所有跟数据相关的决策都有据可查。可视化脚本的价值就在这里它把数据的健康状态直接摊开在你面前而不是让你等到训练翻车才回头查。3.3 样本预览网格检查图片质量与标注一致性可视化脚本第二个核心功能是生成样本预览图代码大致如下import os import math import matplotlib.pyplot as plt from PIL import Image def preview_grid(root, splittrain, n9): class_dirs [d for d in os.listdir(os.path.join(root, split)) if os.path.isdir(os.path.join(root, split, d))] n_classes len(class_dirs) cols 3 rows math.ceil(n_classes / cols) fig, axes plt.subplots(rows, cols, figsize(12, 4 * rows)) for ax, cls_name in zip(axes.flatten(), class_dirs): cls_path os.path.join(root, split, cls_name) imgs [f for f in os.listdir(cls_path) if f.lower().endswith((.jpg, .jpeg, .png))] if imgs: img Image.open(os.path.join(cls_path, imgs[0])) ax.imshow(img) ax.set_title(f{cls_name} ({len(imgs)}), fontsize9) ax.axis(off) plt.tight_layout() plt.savefig(sample_preview.png, dpi150)这段代码的逻辑取每个类别文件夹下的第一张图按类别排成网格标题里带类别名和该类别样本数。一眼扫过去能发现两类问题一是文件夹里的图是不是真的是这种草药——爬虫采集的数据经常混入网图、表情包、白底截图预览图一眼就能揪出来二是图片尺寸是否统一如果有一些图明显是拉伸变形的那是采集时没有统一 resize 的后遗症。参数上比较常调的是 n 和 figsize。n 决定每类取几张figsize 的第二个值按 4 * rows 来类别多了会自动撑高。如果某个类别下第一张图恰好是坏的比如全黑或者损坏无法解码PIL 的 Image.open 不会立刻报错要等到 load() 或者转数组的时候才会炸这点在避坑章会专门讲。预览网格图还有一个隐蔽的用途检查同一类别内部的风格一致性——如果同一类叶片有的带花、有的带果、有的只有叶脉特写说明采集标准不统一这会影响模型学到的判别特征。4. 把数据集接进分类模型PyTorch 加载与训练实战4.1 ImageFolder 直接加载最快跑通的方式如果你只是想快速验证这份数据的可训练性用 torchvision 的 ImageFolder 是最省事的from torchvision import datasets, transforms transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) train_ds datasets.ImageFolder(herb_leaf_data/train, transformtransform) val_ds datasets.ImageFolder(herb_leaf_data/val, transformtransform) test_ds datasets.ImageFolder(herb_leaf_data/test, transformtransform) print(类别数:, len(train_ds.classes)) print(训练样本数:, len(train_ds)) print(类别索引:, train_ds.class_to_idx)逻辑说明Resize 把输入统一到 224x224这是 ResNet 系列的标准输入尺寸ToTensor 把 PIL Image 转成 tensor 并把像素从 0-255 缩放到 0-1Normalize 用的是 ImageNet 的均值方差。这里有个细节如果后续要用 ImageNet 预训练权重做迁移学习归一化参数必须和预训练一致否则迁移效果会明显变差。运行后打印的类别索引和 2.2 节提到的 class_dict.json 需要做一次对齐校验。建议把校验步骤直接写进数据加载模块每次训练前自动比对对不上就抛异常而不是等训练完才发现标签错位。这个习惯能帮你挡掉一大类看起来在跑、实际在乱学的问题。4.2 自定义 Dataset读取类别字典做显式映射ImageFolder 虽然方便但它把类别名-索引的映射封在内部对不上 class_dict 时很难排查。更可控的做法是自己写一个 Dataset显式读字典文件import json import os from PIL import Image from torch.utils.data import Dataset class HerbLeafDataset(Dataset): def __init__(self, root, class_dict_path, transformNone): self.transform transform with open(class_dict_path, r, encodingutf-8) as f: self.class_dict json.load(f) # 反查中文名 - ID self.name2id {v: int(k) for k, v in self.class_dict.items()} self.samples [] for cls_name in os.listdir(root): cls_path os.path.join(root, cls_name) if not os.path.isdir(cls_path): continue # 文件夹名形如 001_甘草取下划线后的中文名查字典 herb_name cls_name.split(_, 1)[1] label self.name2id.get(herb_name) if label is None: print(f[警告] 未在字典中找到: {cls_name}) continue for fname in os.listdir(cls_path): if fname.lower().endswith((.jpg, .jpeg, .png)): self.samples.append((os.path.join(cls_path, fname), label)) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] img Image.open(path).convert(RGB) if self.transform: img self.transform(img) return img, label这段代码的逻辑构造时读取 class_dict.json把中文名映射到整数 ID遍历根目录下的类别文件夹用序号_中文名里的中文名部分去查字典得到标签把所有 (图片路径, 标签) 对存进 samples 列表。加载图片时强制 convert(RGB)把灰度图、RGBA 图统一成三通道避免模型输入的通道数不统一。参数说明root 传 train、val 或 test 的路径class_dict_path 传字典文件的完整路径transform 和 ImageFolder 用法一致。这个实现的好处是标签来源永远是字典文件而不是文件夹名的字母序两者不一致时会在构造阶段就打印警告而不是让错误静默发生。对中文文件夹名的处理用了 split(_, 1)[1]这个 split 只切第一处下划线保证类别名里如果本身带下划线也不会被切坏。4.3 训练循环与评估指标数据集准备完后训练部分我用一个简化的 ResNet18 迁移学习做示例import torch import torch.nn as nn from torchvision import models from torch.utils.data import DataLoader num_classes len(class_dict) model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) model.fc nn.Linear(model.fc.in_features, num_classes) device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.fc.parameters(), lr1e-3) train_loader DataLoader(train_ds, batch_size32, shuffleTrue, num_workers4) val_loader DataLoader(val_ds, batch_size32, shuffleFalse, num_workers4) for epoch in range(10): model.train() running_loss 0.0 for inputs, labels in train_loader: inputs, labels inputs.to(device), labels.to(device) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * inputs.size(0) epoch_loss running_loss / len(train_loader.dataset) print(fEpoch {epoch1:02d} loss: {epoch_loss:.4f})说明这里只微调最后的全连接层前面特征提取层用 ImageNet 预训练权重冻结适合数据量不大的中草药叶片分类。num_classes 从 class_dict.json 的长度读取不要写死。batch_size 32 在常见 8G 显存下没问题如果报显存不足改成 16。num_workers 在 Windows 上如果报错改为 0 或 2。训练完的评估要注意一点test 集的评估只能做一次。如果你反复用 test 集调阈值、调超参最终报出来的精度就是乐观偏估计。正确做法是 val 集上做早停和选模型test 集只用来出最终报告。评估时除了看准确率还要看每个类别的召回率和混淆矩阵中草药叶片类别之间视觉相似度很高比如薄荷和鱼腥草的叶缘形态接近混淆矩阵能告诉你模型到底在哪些类之间犹豫。5. 踩坑与排查中草药叶片数据集最常见的五个坑这份资源整体质量不错但实际用下来还是有几个坑值得记下来。以下全是真实会遇到的情况按现象 - 原因 - 解决写。5.1 坑一类别字典和文件夹名偶尔对不上现象训练 loss 正常下降验证准确率却一直在低位徘徊比如 50% 上下浮动打印混淆矩阵发现预测标签整体偏移。原因文件夹名是序号_中文名而 class_dict.json 的 ID 是按另一个顺序生成的。文件夹在文件系统里的排列顺序、ImageFolder 的字母序、字典文件的键序三者在某些前缀比如 010 和 02下会不一致导致同一个类别在训练时被贴上了错误的标签。这种错位不会报错只会让模型学得稀里糊涂是最难排查的一类问题。解决加载后立刻做 2.2 节的比对脚本把 ImageFolder 的 class_to_idx 和 class_dict.json 逐项核对。我自己的做法是在数据模块里加一个 assert不一致直接中断训练assert imagefolder_order dict_order, 类别映射不一致请检查 class_dict.json5.2 坑二Windows 和 Linux 的路径分隔符不一致现象脚本在 Windows 上好好的换到 Linux 服务器上跑提示找不到文件或者反过来。原因代码里如果写死了 \ 分隔符在 Linux 上就会路径拼接出错。这个资源里的脚本用的是 os.path.join本来没问题但如果你自己写了 f{root}/{cls}/{img} 这种硬编码跨平台就翻车。解决统一用 os.path.join 或 pathlib.Path。另外注意 Windows 下文件名里的中文编码如果 zip 是在 Windows 解压的文件名编码通常是 GBK传到 Linux 上会乱码。解决方法是解压后用 Python 做一次重命名或者用 7-Zip 以 UTF-8 编码解压。5.3 坑三类别不平衡被整体准确率掩盖现象训练 20 个 epoch验证准确率到了 90%看起来不错但看每个类别的召回率有六七个类别低于 60%。原因中草药叶片本身采集难度差异大样本数少的类别对 loss 贡献小模型倾向于把所有不确定样本都判成多数类。整体准确率被多数类拉高了属于典型的平均指标骗人。解决先用 3.2 节的直方图确认哪些类别样本数偏少。训练时给 CrossEntropyLoss 传 weight按类别样本数的倒数加权import torch from collections import Counter counts Counter([label for _, label in train_ds.samples]) class_counts torch.tensor([counts[i] for i in range(num_classes)], dtypetorch.float) class_weights class_counts.sum() / class_counts criterion nn.CrossEntropyLoss(weightclass_weights.to(device))逻辑说明class_weights 中每个类别的权重为总样本数除以该类别样本数样本越少权重越高。这样少数类的 loss 贡献被放大模型不会一面倒。注意 weight 要放到和模型相同的 device 上否则 CUDA 会报错。5.4 坑四PIL 打开损坏图片不报错现象训练到一半突然崩了报错OSError: image file is truncated或者某张图打开是黑的但代码跑完没任何提示。原因爬虫采集的图片里偶尔有下载不完整的 JPEG文件头正常但数据区截断。PIL 的 Image.open 是惰性加载只是读文件头要到图像数据被访问时才真正解码所以错误会在训练中途爆出来位置随机极难复现。解决数据加载阶段做一次全量体检把能解码的图挑出来from PIL import Image def validate_images(sample_list): valid [] for path, label in sample_list: try: with Image.open(path) as img: img.load() valid.append((path, label)) except (OSError, SyntaxError): print(f[跳过损坏图片] {path}) return valid这段代码的关键是 img.load()它强制触发完整解码把损坏图提前暴露在训练之前。注意要用 with 管理文件句柄否则 Windows 下文件会被占用后面读取时也会报错。5.5 坑五可视化脚本输出中文乱码现象sample_preview.png 的标题里中文全是方块或者 stats.txt 里中文变问号。原因matplotlib 默认字体不支持中文Linux 服务器上尤其常见另外 Windows 终端默认编码可能是 GBK脚本里如果没指定 UTF-8 输出print 中文就乱。解决在脚本开头设置 matplotlib 中文字体并强制 UTF-8 编码import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei, Noto Sans CJK SC, WenQuanYi] plt.rcParams[axes.unicode_minus] False import sys sys.stdout.reconfigure(encodingutf-8)font.sans-serif 按系统可用字体配置Windows 用 SimHeiLinux 用 Noto Sans CJK SC 或 WenQuanYi。axes.unicode_minus 设为 False 是为了让负号正常显示。这一步不做你在预览图上看到的就是乱码而不是叶片。很多人在这一步直接弃用中文标签改英文能绕过去但没必要数据集的类别本来就是中文名用字典文件做映射时保持中文一致后面写报告也省事。6. 进阶用法把可视化脚本改造成数据体检工具基础的可视化脚本只做三件事统计分布、出预览图、打印摘要。但你真要在模型上花时间之前还需要更多维度的体检。我现在拿到任何数据集都会先跑一遍下面这套扩展你可以把它追加到脚本里不需要另起炉灶。第一个扩展是尺寸与宽高比统计。叶片照片有的来自手机拍摄高分辨率、比例随意有的来自扫描仪相对统一混在一起如果不做检查Resize 的时候会被强制压扁影响细粒度特征的辨识。扩展逻辑from collections import defaultdict from PIL import Image def collect_aspect_ratios(sample_list): stats defaultdict(list) for path, label in sample_list: with Image.open(path) as img: w, h img.size stats[label].append((w / h, w, h)) return stats把宽高比分布画成箱线图如果某个类别明显偏离整体说明采集来源混入了不同设备训练时要么单独处理要么加 RandomResizedCrop 增强。第二个扩展是亮度与对比度统计。叶片识别的关键特征是叶脉、叶缘和纹理图片过暗或过曝都会把这些细节抹掉。用 PIL 转灰度后算像素均值和标准差大致能反映亮度和对比度import numpy as np def brightness_stats(img): gray np.array(img.convert(L), dtypenp.float32) return gray.mean(), gray.std()第三个扩展是输出一份 CSV 体检报告把每个类别的样本数、平均宽高比、平均亮度、损坏图数量汇总。比起逐张看图CSV 更适合快速扫描异常类别import csv with open(data_health_report.csv, w, newline, encodingutf-8) as f: writer csv.writer(f) writer.writerow([类别, 样本数, 平均宽高比, 平均亮度, 损坏图数]) for cls_name in class_list: writer.writerow([cls_name, count, avg_ratio, avg_brightness, bad_count])这套体检跑了之后如果数据本身的问题——类别不平衡、损坏图、尺寸混乱——全部在训练前暴露干净训练阶段就只剩下模型层面的问题排障半径缩小一大半。还有一个我个人的习惯每次跑训练前把 train/val/test 三个子集各随机抽 20 张图存到一个文件夹里训练完用模型预测一遍把预测结果和真实标签对照看。这个小样本人工抽检成本很低但能发现那些指标看不出来的问题——比如模型可能对某一类有系统性误判而混淆矩阵数字不一定直观。从那以后我每次拿到新数据集都强制走一遍先比对类别字典、再跑可视化脚本、然后做全量解码体检最后才写训练代码。这套流程救过我太多次愿你这份数据也一样顺利跑通。希望帮到你。本文还有配套的精品资源点击获取
返回列表