
简介面向计算机专业毕业设计与图像识别学习者这是一份基于深度学习的完整中草药识别项目源码包。系统采用ResNet残差网络结合CBAM注意力机制解决深层网络梯度消失问题在10类共2500张中草药图像上完成训练与评估并应用数据增强提升泛化能力。压缩包共2000个文件约198.37MB其中1976个jpg组成药材图像数据集12个py为PyTorch模型构建、训练和推理脚本另有md说明文档、html训练报告、json配置与inscode工程入口便于直接复现。已有207人浏览学习适合毕业设计、图像分类实战或课程项目参考。通过源码可学习残差连接与通道/空间注意力模块的代码实现掌握数据预处理、超参数调优、日志记录到结果可视化的完整流程包内附带TensorBoard事件文件与笔记文档方便对照模型训练过程深入理解。1. 深度学习的中草药识别为什么这个方向值得动手做在药店质检、标本数字化、野外调查这些场景里靠肉眼认药材一直是个高门槛的活计。中草药识别本质上是一个细粒度图像分类问题——很多药材在外形上极其相似比如同一科的几种根茎切片颜色、纹理差异极小非专业人士基本分不清。深度学习在这类任务上的优势在于只要数据足够干净卷积网络能捕捉到人眼容易忽略的叶脉走向、断面纹理、粉末色泽等局部特征。这个[项目源码]方向适合两类人一类是刚做完图像分类入门、想找一个真正有落地价值的实战项目来巩固迁移学习和训练流程的同学另一类是中药相关行业的从业者想把手头的标本照片变成一套能自动判别的工具。本文按数据准备、模型选型、训练调参、避坑、部署这条主线把完整方案讲透所有代码都可以直接跑通。2. 中草药数据集怎么造从零构建类别均衡的训练集2.1 数据来源与采集策略别迷信公开数据集做中草药识别的第一个坎不是模型是数据。网上能搜到的中药图片集大多存在三个问题类别覆盖不全、单类数量差异悬殊、图片带水印和说明书背景。某开发者的经验是与其满世界找现成数据集不如自己拍一套标准化的标本图。采集策略上我一般按“每类至少 300 张、总数 30 类起步”来定盘。拍摄时统一背景白色或浅灰A4纸、统一光源自然光或LED台灯均可但避免强阴影、保持手机镜头与样本平面平行。三类图片必须都收整株植物、干燥药材饮片、切片特写。这样模型在推理时不管用户拍的是地里的活体还是药房里的干片都能对上号。2.2 目录组织与标签文件train/val/test 的三级结构数据拍完后第一步是整理目录。PyTorch 的ImageFolder直接要求按类别建文件夹这是最快、最不容易出错的组织方式。medicinal_herbs/ ├── train/ │ ├── ginseng/ │ ├── mint/ │ ├── goji/ │ └── ...(每个类别一个文件夹) ├── val/ │ ├── ginseng/ │ ├── mint/ │ └── ... └── test/ ├── ginseng/ └── ...拍完照后建议先手工筛掉模糊、对焦不准、背景杂乱的图片再随机划分。常见划分比例是 train 70%、val 15%、test 15%如果你的单类图片不多也可以把 val 压到 10%。关键在于 test 集必须和 train 集来自不同的拍摄批次否则验证指标会虚高——这个问题在第五章还会专门展开。2.3 用 Python 脚本自动划分目录手动拖拽几百个文件不现实直接跑这个脚本把原始采集目录按比例拆分。import os import random import shutil source_root raw_herbs # 原始采集目录raw_herbs/类别名/*.jpg target_root medicinal_herbs split_ratio {train: 0.7, val: 0.15, test: 0.15} seed 42 random.seed(seed) for cls_name in os.listdir(source_root): cls_path os.path.join(source_root, cls_name) if not os.path.isdir(cls_path): continue imgs [f for f in os.listdir(cls_path) if f.endswith((.jpg, .jpeg, .png))] random.shuffle(imgs) n_train int(len(imgs) * split_ratio[train]) n_val int(len(imgs) * split_ratio[val]) for split_name, split_imgs in [ (train, imgs[:n_train]), (val, imgs[n_train:n_train n_val]), (test, imgs[n_train n_val:]), ]: out_dir os.path.join(target_root, split_name, cls_name) os.makedirs(out_dir, exist_okTrue) for img_name in split_imgs: shutil.copy( os.path.join(cls_path, img_name), os.path.join(out_dir, img_name), ) print(f{cls_name}: total{len(imgs)} train{n_train} val{n_val} test{len(imgs) - n_train - n_val})逻辑其实很直白遍历原始目录下的每个类别文件夹把所有图片路径打乱再按 0.7/0.15/0.15 的比例切三段copy 到目标目录。这里的seed 42就是玄学但不翻车的固定随机种子保证你每次跑出来的划分完全一致后续排查数据问题时不会被“随机划分”坑到。n_val必须用int()取整否则切片索引会报错。如果你的某类图片不足 20 张建议对这个类别单独做增强扩充后再划分否则 val/test 里每类只有两三张指标波动会非常大。2.4 类名映射给中文名配稳定的索引字典中草药名存在别名问题同一个“金银花”也有“忍冬”的别称。训练前必须锁死一份中文名到整数索引的映射存成 JSON后续推理和训练都从这里读。import json class_names [ginseng, mint, goji, licorice, dried tangerine peel] class_to_idx {name: i for i, name in enumerate(class_names)} with open(class_to_idx.json, w, encodingutf-8) as f: json.dump(class_to_idx, f, ensure_asciiFalse, indent2) print(class_to_idx)映射文件的indent2是为了让人眼可读方便你随时打开核对。ensure_asciiFalse保证中文类别名直接落盘不会变成\uXXXX转义序列。建议这个 JSON 文件放在项目根目录并纳入版本管理否则不同机器上的类别顺序一变训练好的模型权重就废了。实际项目里我见过某同学因为重跑数据划分导致类别索引变动之前保存的 checkpoint 全部作废只能从头训这就是血泪教训。3. 模型选型与构建中草药识别该用什么网络3.1 为什么选轻量 CNN 而不是从头设计网络中草药识别的数据量一般在几千到几万张的规模从头训练一个深度网络基本不可行也没有必要。这个领域最可靠的路径是迁移学习选择一个在 ImageNet 上预训练过的轻量主干冻结前几层、微调后面几层。综合精度和推理速度ResNet-18和EfficientNet-B0是最稳的两个选择。ResNet-18结构简单、调参经验多任何深度学习框架都能跑出问题好排查适合第一版快速验证。EfficientNet-B0同精度参数更少、速度更快但训练时对学习率更敏感需要多试两组初始学习率属于“上限更高、下限也更低”的选项。更重的ResNet-50如果你的样本量达到每类 1000 张以上可以换这个否则容易过拟合。常见做法是拿 ResNet-18 把流程跑通确认数据没有问题之后再换 EfficientNet-B0 压精度。不要一上来就上大模型数据集只有两万张时大模型不会带来明显提升反而拖慢迭代。3.2 构建带 Dropout 的分类头下面这段代码把torchvision里预训练的 ResNet-18 拿出来把最后一层全连接替换成适合中药类别数的分类头同时加一个 Dropout 抑制过拟合。import torch.nn as nn from torchvision import models def build_model(num_classes: int, pretrained: bool True) - nn.Module: model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1 if pretrained else None) in_features model.fc.in_features # ResNet18 是 512 model.fc nn.Sequential( nn.Dropout(0.3), nn.Linear(in_features, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(256, num_classes), ) return model model build_model(num_classes30) print(model)逻辑说明in_features从原来的fc层取这样即使换了 EfficientNet 也不用改代码。中间的 256 维全连接层相当于给分类器增加了一个非线性变换的缓冲直接拿 512 维特征映射到 30 个类别也不是不行但加上一层能让特征表达更稳。Dropout 0.3 是经验值类别少10 类以内可以降到 0.2类别多50 类以上建议提升到 0.4。inplaceTrue只是省显存不影响精度。3.3 初始化方式与冻结策略预训练模型的初始化方式决定了训练是“微调”还是“从零重学”。weightsIMAGENET1K_V1会自动下载对应权重到~/.cache/torch/hub/checkpoints。第一次跑会稍慢后续会直接命中缓存。建议第一轮训练时先冻结layer1之前的所有层只训练layer3、layer4和分类头这样显存占用小收敛也快等第一轮收敛后再解冻全部层用小学习率微调 10 个 epoch精度通常还能再涨 1-2 个百分点。3.4 对比小结两种主干的取舍表维度ResNet-18EfficientNet-B0预训练权重源ImageNetImageNet输入分辨率224×224224×224参数量约 11M约 5.3M推理速度快更快训练稳定性高中对学习率敏感适合场景第一版快速验证需要部署到手机/边缘设备如果是跑通流程选 ResNet-18如果后续要往手机端落地直接上 EfficientNet-B0 并配好学习率衰减能省掉一次模型替换的重复工作。还有一条经验任何主干都要做输入尺寸的归一化中草药图片的明亮度差异很大mean[0.485, 0.456, 0.406]、std[0.229, 0.224, 0.225]这套 ImageNet 统计值直接沿用即可。4. 训练与调参让模型真正学会看药材纹理4.1 数据增强中草药图片的增强策略与普通分类不同中草药图片的判别特征集中在纹理、边缘和颜色上。常规的随机裁剪增强虽然能提升鲁棒性但会过度拉伸茎叶的形状。我常用的增强组合是随机缩放裁剪 轻微随机旋转 颜色抖动 水平翻转。其中颜色抖动的幅度要控制好幅度过大会把黄棕色药材变成紫色破坏真实判别信息。from torchvision import transforms train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.7, 1.0)), transforms.RandomRotation(15), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.1, hue0.05), transforms.RandomHorizontalFlip(p0.5), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) val_transform transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])参数说明RandomResizedCrop(224, scale(0.7, 1.0))的scale下限设为 0.7 而不是默认的 0.08是因为药材图片的主体通常占画面比例较大缩放太小会让模型看到太多背景。RandomRotation(15)只转 15 度因为药材饮片是有方向性的旋转过大反而混淆训练信号。ColorJitter里saturation0.1和hue0.05特意调得很保守这两个参数是防止“颜色失真”的关键。验证集不裁剪、只做等比缩放保证评估结果稳定。4.2 损失函数与标签平滑处理相似类别中草药里相似类别太多比如“枸杞”和“酸浆果”的干制切片外观非常接近。直接用交叉熵损失训练模型容易被某几个相似类别带偏在训练集上得到 99% 的准确率却在相似对子上反复翻车。标签平滑Label Smoothing能有效缓解这个问题它把硬标签0 或 1替换成平滑概率让模型不再过度自信。import torch.nn.functional as F def smoothed_ce_loss(logits, labels, smoothing0.1): n_classes logits.size(1) one_hot F.one_hot(labels, n_classes).float() smoothed_labels one_hot * (1 - smoothing) smoothing / n_classes log_probs F.log_softmax(logits, dim1) loss -(smoothed_labels * log_probs).sum(dim1).mean() return loss这里的逻辑是把原来的[0,1]硬标签变成[0.9/30, 0.90.1/30, ...]这类平滑分布。平滑系数 0.1 是经过几组对比测试的常用落点太低0.01几乎没有效果太高0.3会让模型欠拟合。换成这个损失之后不需要改动任何训练代码直接替换原来的nn.CrossEntropyLoss()即可。4.3 训练脚本核心优化器、学习率调度与最小闭环训练脚本里最关键的三件事是优化器选 AdamW 还是 SGD、学习率怎么退火、梯度裁剪设不设。对于中草药识别这类中小规模数据集我用 SGDMomentum 的效果通常好于 AdamW但 SGD 对初始学习率更敏感得配一个余弦退火调度器才稳定。import torch from torch.optim.lr_scheduler import CosineAnnealingLR from torch.utils.data import DataLoader from torchvision import datasets train_dataset datasets.ImageFolder(rootmedicinal_herbs/train, transformtrain_transform) val_dataset datasets.ImageFolder(rootmedicinal_herbs/val, transformval_transform) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue) optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max30, eta_min1e-5) criterion smoothed_ce_loss model.train() for epoch in range(30): running_loss 0.0 for images, labels in train_loader: images, labels images.to(cuda), labels.to(cuda) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() running_loss loss.item() * images.size(0) scheduler.step() print(fEpoch {epoch1}: loss{running_loss / len(train_dataset):.4f})参数细节说明batch_size32在 8G 显存下训练 ResNet-18 比较稳显存不够就压到 16。lr0.01是微调阶段的常用起点如果是从零训练务必降到 0.001。weight_decay1e-4控制 L2 正则强度数据量大可以提到 5e-4。T_max30要和总 epoch 数一致否则余弦退火的曲线会截断。最容易被忽略的是clip_grad_norm_(max_norm5.0)中草药数据里偶尔会出现过曝或异常样本一两个离群点就可能让梯度爆炸导致 loss 变成 NaN加上裁剪等于给训练过程上了一个保险。4.4 学习率的动态调整策略搜到的资料里经常有人把学习率调度写成固定步长衰减比如每 10 个 epoch 降一半。我的经验是余弦退火在中草药这种细粒度分类上稳定胜出因为类别差异越小损失面越崎岖平缓退火比阶梯下降更容易滑到好的局部最优。启动训练时可以先跑 5 个 epoch 做热身把学习率从 0 线性升到 0.01再进入余弦退火。这个热身步骤对缓解初期震荡有帮助尤其是换了 EfficientNet-B0 之后。5. 中草药识别避坑清单5 个高频翻车点与排查5.1 类别不均衡严重模型把所有样本都推给高频类别现象训练 loss 降得很低但 val 的 F1 值一塌糊涂查看混淆矩阵发现模型几乎只预测“枸杞”这一类。原因自采数据里枸杞的照片特别多1000张而“木通”只有 80 张。网络学到的决策边界整体偏向高频类别因为把所有样本都预测成枸杞也能把整体 loss 压下来。解决按类别数量设置采样权重或者干脆对低频类别做复制增强。简单可靠的方案是用WeightedRandomSampler每个样本的采样权重与所在类别数量成反比相当于在线做了平衡。注意这只对训练集采样val/test 保持原始分布才能反映真实场景。5.2 模型过拟合到背景而不是药材本身现象训练集准确率接近 100%val 准确率却不到 70%且 val 中换成纯色背景的图片后准确率骤降。原因采集时部分照片的背景里出现了白色的药袋、木桌纹理等强特征网络偷懒直接学了背景与类别的相关关系根本没看药材本体。解决回到 2.1 重新裁剪把所有主体区域尽量占满画面背景统一。另一种思路是增强时加RandomErasing随机擦除强迫网络不依赖单一区域但这只能缓解不能根治。最有效的手段仍然是数据清洗把背景杂乱的图直接删掉别心疼。5.3 验证集的“同源泄露”导致指标虚高现象val 准确率达到 95%上线到真实场景用户手机拍的图片却跌到 74%反查发现训练集和验证集来自同一天、同一光线条件下的同一批拍摄。原因划分数据时没有把“同一次拍摄”作为一个整体进行切分而是每张图独立随机分配。导致同一株药材的照片同时出现在 train 和 val 里模型记住的是纹理细节而不是泛化特征。解决按拍摄批次划分数据保证 val 和 test 的图片来自模型没见过的拍摄轮次。这是中草药识别项目里最容易犯、也最伤真实验证指标的错误没有之一。5.4 推理时预处理不一致权重加载后精度崩盘现象训练好的模型准确率 93%导出后放到一个简单的 Web 服务里同一张测试图准确率只有 30%。原因训练时的预处理是RandomResizedCrop Normalize推理脚本只写了Resize(224) ToTensor()忘了做 Normalize。模型吃到的输入分布完全不同输出概率自然乱了套。解决把推理预处理单独封装成一个函数与val_transform严格保持一致。建议直接把val_transform定义在一个公共模块里训练和推理 import 同一个对象从源头上杜绝写法漂移。5.5 类别名编码不一致加载 checkpoint 报错或预测错位现象加载训练好的.pth文件时报num_classes mismatch或者不报错但预测结果全部错位一位。原因首次训练用的class_to_idx.json是字母序后来数据目录被重排过一次重新生成了映射文件导致模型输出概率对应的类别索引整体偏移。解决版本管理机器学习项目的黄金法则是——每次训练时把class_to_idx.json的内容打印到日志里并把它连同 checkpoint 一起打成压缩包归档。恢复现场时只用压缩包里的映射文件绝不从当前目录重新生成。6. 部署与最后的精度验证ONNX 导出与简易 TTA6.1 把模型导出为 ONNX 并检查输入输出训练完成后部署到服务端或手机端前先把 PyTorch 权重导出为 ONNX 格式。ONNX 的好处是跨平台、不依赖 PyTorch 运行时后续转成 TensorRT、ONNX Runtime 或各种移动端推理引擎都方便。import torch model.eval() dummy_input torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, herb_model.onnx, input_names[input], output_names[logits], dynamic_axes{input: {0: batch_size}, logits: {0: batch_size}}, opset_version12, ) print(ONNX export done.)dynamic_axes把 batch 维度声明为动态这样部署端既能一次推理单张图片也能批量推理多张不受固定 shape 限制。opset_version12是兼容性最稳妥的选择更高版本可能导致旧版推理引擎不认。6.2 简易 TTA不引入任何依赖的精度提升技巧如果你不想引入额外的测试时增强代码库最直接的 TTA 方案就是把测试图做一次水平翻转把两次预测的 logits 平均一下再取 argmax。这个方法在中草药识别上通常能带来 1 2 个百分点的准确率提升而代价只是推理时间翻倍。import numpy as np def infer_with_tta(model, pil_image, transform, devicecuda): from torchvision import transforms as T import torchvision.transforms.functional as TF tensor transform(pil_image).unsqueeze(0).to(device) flipped TF.hflip(tensor) model.eval() with torch.no_grad(): logits_orig model(tensor) logits_flip model(flipped) avg_logits (logits_orig logits_flip) / 2 pred_idx int(torch.argmax(avg_logits, dim1).item()) return pred_idx这里的关键在于把TF.hflip用在经过 Normalize 之后的张量上。有人会在 PIL 图层面翻转再重新走一遍 transformSA结果一致但多了一次 Normalize 计算。注意翻转前模型输入维度是(1,3,224,224)torchvision.transforms.functional.hflip可以直接作用在这个张量上。如果 TTA 没有带来提升不要硬上说明数据集本身的方向敏感性已经很低。6.3 结尾把指标留给测试集把容错留给代码我在带这个方向的项目时反复跟同学强调一个习惯任何时候汇报准确率只报 test 集上跑出来的数字val 的数字只用于调参过程。因为 val 集在调参过程中已经被间接“看过”很多次了指标已经带上了选择偏差。最后真正验证模型价值的方法是拿手机到自然环境下拍一批照片走一遍推理链路看 top-3 是否命中。中药识别不可能做到 100%能稳定给用户提供 top-3 候选并配合人工确认才是这个项目的实用形态。希望这篇笔记帮你把从数据到部署的整条链路走通少走我当年走过的弯路。本文还有配套的精品资源点击获取