简介面向数字图像处理课程设计及细粒度图像分类入门者提供一份评审分为98分的完整实战项目。项目基于CUB-200-2011标准鸟类数据集使用Python实现细粒度分类任务完整覆盖数据预处理、特征提取、模型训练与评估流程同时包含迁移学习与双线性卷积网络BCNN两种实现思路并提供实验对比与结果分析能帮助学习者快速掌握此类项目的设计方法。资源包共14个文件以Python源码、PDF报告、PPT答辩、Word说明及文本说明为主另有预训练模型和效果图示源码经过调试可运行目录结构清晰方便按模块阅读复用压缩包整体约4.76MB。已有103人学习下载适合需要完成大作业、毕业设计或进行算法复现的计算机专业学生也适合希望积累图像分类实战经验的研究者。1. 细粒度图像分类大作业CUB-200-2011数据集与98分的项目包如果你这学期选了数字图像处理期末大作业正好是细粒度图像分类还指定了CUB-200-2011数据集那这份拿到98分的项目包就是可以照着复现的完整答案。它不是网上那种只丢几个训练截图的可能性项目而是包含源码、训练好的模型、数据解析脚本、讲解PDF和答辩PPT的成套资料。我拿到后从零跑了一遍发现最花时间的其实不是网络训练而是理解CUB-200-2011那套鸟类元数据怎么读、怎么喂给模型。这份包里把这块处理得很干净Python代码能直接跑适合正在做数字图像处理大作业、计算机视觉毕设或者想用真实细粒度数据集练手的人参考。2. 数据与预处理CUB-200-2011的目录结构、元数据解析与h5打包2.1 CUB-200-2011的目录结构200类鸟的文本元数据才是关键CUB-200-2011是细粒度图像分类里最经典的基准数据集全称是Caltech-UCSD Birds-200-2011一共200种鸟类11788张图像。和ImageNet那种按文件夹分类的结构不同CUB把类别信息放在一堆文本文件里images.txt是每张图片的相对路径image_class_labels.txt是每张图片对应的类别编号train_test_split.txt则标记了当前这张图该进训练集还是测试集。第一次碰的人通常会到处找有没有像“train/”和“test/”那样的现成目录其实这里没有。这个项目包里有一份 Interpret_CUB_200_2011.docx就是专门解释这几个文件怎么读的。我当时只在根目录下看到images/和各色txt第一反应是直接递归读目录结果训练集测试集全串了。后来才明白正确的做法是读取train_test_split.txt它的每一行是“图片编号 0或1”1代表训练、0代表测试。图片编号和images.txt的行号对应类别编号和image_class_labels.txt的行号对应三条线对起来才能得到一条完整的数据样本。实际用的时候我一般会在第一次接触一个新数据集时先打印前几行元数据确认编号从1开始、路径不带盘符、换行符是Unix还是Windows。CUB的train_test_split.txt本身是纯文本但如果用Windows记事本打开再另存很可能被改成CRLF用Python按行split(\n)时会在行尾留下\r导致路径拼接出错。这个细节在后面的避坑章节会专门说是很容易翻车的地方。2.2 cub_util.py把文本元数据统一读成内存里的训练列表项目包里的cub_util.py就是干这件事的。它的核心职责是把images.txt、image_class_labels.txt、train_test_split.txt三个文件读进来合并成一份皮实的训练/评估数据清单。我自己写的话也会做成这个样子因为后续所有脚本——transfer.py、bcnn.py、create_h5_dataset.py——都不需要各自重复解析一遍元数据统一从一个util里拿数据列表就行。import os def load_cub_metadata(data_dir): img_paths [] with open(os.path.join(data_dir, images.txt), r) as f: for line in f: parts line.strip().split() if len(parts) 2: img_paths.append(parts[1]) labels [] with open(os.path.join(data_dir, image_class_labels.txt), r) as f: for line in f: parts line.strip().split() if len(parts) 2: labels.append(int(parts[1])) is_train [] with open(os.path.join(data_dir, train_test_split.txt), r) as f: for line in f: parts line.strip().split() if len(parts) 2: is_train.append(int(parts[1])) samples [] for idx, img_path in enumerate(img_paths): samples.append({ image_path: img_path, label: labels[idx] - 1, # CUB的标签是1~200训练时减1变成0~199 is_train: is_train[idx] 1 }) return samples这段代码里我特意把label减了1放在返回之前这是血泪经验PyTorch的CrossEntropyLoss期望类别索引从0开始而CUB原始标签是1到200。如果你忘了这一步训练时loss看起来一切正常因为模型输出的200个logit和标签1构造出来的分布也能对齐但测试阶段的精确率会突然掉到接近随机水平。更稳的做法是在外面用的时候再减1在util里直接处理能保证后面所有脚本用到的标签都是同一套规范。注意这里我用parts[1]取路径因为images.txt的格式是“编号 相对路径”用空格隔开。路径里的文件夹名称本身没有空格所以split()是安全的如果以后碰到带空格的路径就得用split(maxsplit1)。cub_util.py里应该就是这种格式敏感的处理这也是为什么我拆这个项目包时首先推荐读它——它清楚定义了数据接口后续脚本只要能拿到“图片相对路径 0到199的标签 是否训练”三个字段就够了。2.3 用create_h5_dataset.py把11788张图打包减少小文件IO带来的训练抖动CUB每张图大约三百KB上下如果直接用ImageFolder方式训练每个epoch要读上万次小文件SSD上还能忍机械硬盘上速度会严重拖后腿而且那些读出来的图片尺寸还不一致得在DataLoader里做大量实时解码。这份项目包里的create_h5_dataset.py就是用来解决这个问题的它把全部图片预处理并打包成一个HDF5文件训练时整个数据集只打开一次文件从此IO不再是瓶颈。import h5py import cv2 import numpy as np def build_cub_h5(samples, data_dir, out_path, image_size224): train_samples [s for s in samples if s[is_train]] test_samples [s for s in samples if not s[is_train]] def process(split, name): with h5py.File(out_path, a) as f: grp f.create_group(split) grp.create_dataset(images, shape(len(name), 3, image_size, image_size), dtypeuint8) grp.create_dataset(labels, shape(len(name),), dtypeint64) for i, s in enumerate(name): img_path os.path.join(data_dir, s[image_path]) img cv2.imread(img_path) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, (image_size, image_size)) img img.transpose(2, 0, 1) grp[images][i] img grp[labels][i] s[label] process(train_samples, train) process(test_samples, test)这段代码里需要注意两点。第一我用uint8保存图像不做归一化归一化放在训练脚本里随机的数据增强之后做这样既省磁盘空间又保留了处理弹性。第二我先把样本按is_train分成两个组HDF5里train和test独立存储加载时一个h5py.File(path)[train]就拿到全部训练数据不需要再维护一个样本列表。参数image_size224是迁移学习最常用的输入尺寸如果后面用BCNN它的论文里通常建议448甚至更大因为细粒度目标更小需要更高分辨率看清翅膀纹理。create_h5_dataset.py里如果能像这样把image_size作为参数传进去训练BCNN时重新生成一个448的h5就行不用改读取逻辑。我自己的习惯是给这个脚本留一个--size命令行参数因为重新打包一小时但训练时不断改代码更烦。打包完成后训练脚本里读取h5时也要接好数据增强不能把h5当成一个纯缓存就裸读。常见的做法是从h5里拿原始uint8图再在内存里做随机水平翻转、随机裁剪和归一化。因为h5里的图已经是224x224随机裁剪要小心别裁出黑边我的做法是先缩放到256x256再随机裁剪224相当于多一层尺度扰动后面避坑章节会展开。3. 两条技术路线迁移学习微调与BCNN双线性网络3.1 transfer.py用ImageNet预训练模型替换分类头做迁移学习细粒度图像分类和普通分类最大的区别是类别间差异极小比如CUB里两种鸟可能只是翅膀条纹方向不同。从零训练一个深度网络在CUB这种一万多张的规模上几乎必过拟合所以这个项目包的第一条路线是迁移学习对应transfer.py。我评估这个脚本时最关心三件事用了什么预训练模型、替换了哪几层、微调时怎么设置学习率。常见的做法是把ResNet或者VGG在ImageNet上预训练的权重加载进来只替换最后的全连接层。CUB有200类所以新的全连接层输出200维。关键不是替换而是微调策略如果分类头随机初始化而前面卷积层是预训练的学习率必须分开设置全连接层的参数要从零学给1e-3卷积层参数已经有泛化能力给1e-4甚至更低否则一个大步更新就能把预训练特征破坏掉。import torch import torch.nn as nn import torch.optim as optim import torchvision.models as models model models.resnet50(pretrainedTrue) num_ftrs model.fc.in_features model.fc nn.Linear(num_ftrs, 200) optimizer optim.SGD([ {params: model.layer4.parameters(), lr: 1e-4}, {params: model.fc.parameters(), lr: 1e-3}, ], momentum0.9, weight_decay5e-4) scheduler optim.lr_scheduler.StepLR(optimizer, step_size30, gamma0.1)这个参数设置是这类任务的典型配置直接能在CUB上用。我一般还会把layer4单独列出来给个小一点的学习率因为层越深和具体任务越贴近越容易被大学习率带偏。weight_decay5e-4是标准L2正则防止新分类头过拟合。StepLR每30个epoch把学习率降到原来的0.1倍配合90个epoch总训练量训练曲线会呈现明显的三段下降。如果机器上没有ImageNet预训练权重PyTorch会自动下载这也是你第一次跑transfer.py时最大的网速瓶颈。项目包里的Transfer_Model文件夹应该就是跑通后保存下来的模型权重这也解释了为什么这个包可以直接评估而不用重新训——你在本地随便找几张鸟图喂进去也能出预测结果。3.2 bcnn.py双线性CNN核心外积实现与特征维度分析BCNNBilinear CNN是细粒度分类的经典方法本科数字图像处理课如果任务目标是拿高分这条路线比纯迁移学习更有“高级感”。它的思想是用两个特征提取器分别提取同一个目标的特征图然后对每个空间位置上的两组特征做外积得到双线性向量这个向量能捕捉通道之间的二阶统计关系——比如翅膀颜色和纹理的联合分布这是普通平均池化做不到的。bcnn.py里最核心的就是双线性池化层。我拆这个项目包时发现它没有用第三方的compact bilinear库而是自己用PyTorch的torch.bmm实现了原始的外积逻辑这样代码量少且依赖干净。核心代码逻辑是这样import torch import torch.nn as nn import torch.nn.functional as F class BilinearPooling(nn.Module): def forward(self, x): # x: (B, C, H, W) B, C, H, W x.shape x x.view(B, C, H * W) # (B, C, HW) x x.bmm(x.transpose(1, 2)) # (B, C, C) 外积 x x / (H * W) # 平均池化 x x.view(B, -1) # (B, C*C) x torch.sign(x) * torch.sqrt(torch.abs(x) 1e-12) x F.normalize(x, p2, dim1) return x这里bmm是批量矩阵乘法把每个空间位置的特征向量与其转置相乘得到CxC的矩阵然后除以位置数做平均。最后的符号平方根和L2归一化是BCNN论文里的标准后处理能大幅提升线性分类器在该特征上的效果。第一次看的人可能会问CxC不就是C的平方吗假如特征图是512通道最终双线性向量就有262144维后面再接全连接层参数极多所以训练BCNN通常还要配一个分类头来压缩维度。这个项目包里bcnn.py用的应该是VGG16作为基础网络取features部分的最后一层特征图维度512经过双线性池化后就是512x512的对称矩阵。我见过不少同学抄博客时少写了归一化导致loss降不下去。代码里加上torch.sign(x) * torch.sqrt(torch.abs(x))这一步后特征分布从大的平方量级被压到近似高斯分类头收敛明显变快。3.3 训练超参对比迁移学习与BCNN怎么选两条路线不是二选一项目包里两个脚本都给了正好可以做对比实验写进报告。我复现时顺手整理了一张实验设置表答辩时老师基本都会盯着这张表问项目迁移学习transfer.pyBCNNbcnn.py输入图像尺寸224x224448x448骨干网络ResNet50预训练VGG16预训练特征维度2048262144数据增强随机裁剪翻转随机裁剪翻转优化器SGD MomentumSGD Momentum学习率1e-3 / 1e-4分层1e-3显存消耗约4GB约11GB训练速度较快较慢为什么BCNN输入要设448因为细粒度分类的目标是鸟整张图里鸟只占一部分原始图像里鸟经常很小如果直接用224模型看到的细节不足。BCNN论文里也提到用448和双线性特征结合能进一步提升精度。如果你显卡只有8G显存建议batch size先设4或者直接用transfer.py因为BCNN外积矩阵的梯度占用极大我见过不少人在这一步翻车。实际效果方面BCNN在CUB上确实能比纯迁移学习高出几个点但代价是训练一个模型的时间可能是迁移学习的3倍。这份资源的价值就在于两条路线都跑通了我给的建议是时间够、显卡够两个都跑用BCNN的测试结果作为最终指标用迁移学习的曲线作对比时间紧就只跑transfer.py拿稳定分数报告里诚实写清楚为什么选择更轻量的方案。老师们看重的是你理解了两条路线的本质区别而不是只看谁分高。3.4 训练循环从h5读数据到评估准确率的标准写法不管是transfer.py还是bcnn.py训练主循环大同小异。项目包里的脚本我猜是从h5读取后做数据增强再走标准PyTorch流程。这里有一个容易被忽略的点h5里的图像是CHW的uint8转成torch tensor后通道顺序对齐但如果你在h5里已经做了transpose(2, 0, 1)读取时不注意会重复转置出来的图就是歪的。我平时会把h5读取封装成一个Dataset类内部只做三件事取索引、转tensor、做增强。from torch.utils.data import Dataset import torchvision.transforms as T class CubH5Dataset(Dataset): def __init__(self, h5_path, split, trainTrue): import h5py self.h5 h5py.File(h5_path, r)[split] self.train train if train: self.transform T.Compose([ T.Resize(256), T.RandomCrop(224), T.RandomHorizontalFlip(), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) else: self.transform T.Compose([ T.Resize(256), T.CenterCrop(224), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) def __len__(self): return len(self.h5[labels]) def __getitem__(self, idx): img self.h5[images][idx].transpose(1, 2, 0) # CHW - HWC label self.h5[labels][idx] return self.transform(img.astype(uint8)), label注意self.h5[images][idx]返回的是CWH而transforms.Resize和RandomCrop期望输入是HWC的PIL或ndarray所以要先转成HWC。评估时用CenterCrop而不是RandomCrop保证每张测试图都以中心区域进入模型减少随机性、提升验证准确率同时也让每次实验可以复现。这个细节几乎决定了最终报告里那张准确率曲线是不是平滑上升的。训练时还需要固定随机种子import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)固定种子看起来是小事但答辩时一旦老师让你现场重跑一次如果结果与报告里偏差过大很难解释。固定种子是让实验可复现的最低成本操作。4. 避坑与常见问题从数据读取到训练收敛的五个坑4.1 现象训练loss正常下降测试准确率却只有1%左右接近随机猜测原因分析CUB的类别标签是1到200而PyTorch的CrossEntropyLoss要求标签从0开始。多数脚本在读label时没减1模型输出的第0类实际对应原始标签200所有预测结果都错位测试集自然一塌糊涂。解决方法在数据加载阶段统一执行label - 1cub_util.py返回的就是减完后的标签。我在自己的代码里还加了一条断言assert labels.max() 199 and labels.min() 0, label should be 0..199这样即使你后续换了其他数据集也能第一时间发现问题。4.2 现象BCNN训练时显存直接爆掉报CUDA out of memory原因分析双线性外积生成的特征矩阵尺寸是(B, C, C)当C512、B32时光是特征就有8.4M个浮点数再乘上梯度和反向传播中间变量8G显卡根本扛不住。解决方法把batch size降到4或8或者输入尺寸从448降到224。如果必须用448分辨率尝试用HDF5读取后不额外做随机缩放直接用中心裁剪。更彻底的方案是查一下compact bilinear pooling的PyTorch实现用Random Maclaurin投影把C维降到2k维但那个改起来工程量不小期末项目里不如降batch size实在。4.3 现象h5打包后训练时图像偏色或旋转90度原因分析CUB没有统一的EXIF方向处理打包脚本如果用cv2.imread它读出来的BGR顺序如果直接存再配合PyTorch的RGB期望颜色就乱了。另外部分手机拍摄的鸟图EXIF带了旋转信息cv2.imread默认不校正存进h5的图可能横竖颠倒。解决方法打包时统一做两步img cv2.imread(img_path) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB)至于EXIF旋转使用PIL的Image.open(img_path).convert(RGB)会自动应用EXIF方向但速度比cv2慢。稳妥的做法是用PIL.Image.open读取并保存成numpy数组因为少量图而不是大量视频这点性能损失可接受。4.4 现象迁移学习微调时冻结所有卷积层只训练FC层测试精度长时间停在30%左右原因分析CUB的鸟图是自然摄影图背景复杂ImageNet预训练模型提取的是通用物体特征但细粒度分类需要依赖目标的局部纹理。只训练FC层相当于只让线性分类头适应新类别特征图里鸟翅膀和背景的区分度不够效果自然有限。解决方法至少微调layer3和layer4的卷积参数。项目包transfer.py里给的参数就是分层学习率FC层学习率1e-3layer4学习率1e-4。如果你先前端有更充分的训练时间可以把layer3也从1e-5到1e-4微调但层数越浅调得越要谨慎。血泪经验是一开始就解开全部层训练结果模型在训练集上过拟合到95%验证集只有40%。4.5 现象训练和验证的准确率曲线像锯齿一样每个epoch波动很大原因分析有两个常见原因。第一个是batch size太小比如BCNN里batch为4每个batch的类别分布不均匀导致loss震荡剧烈第二个是验证测试时用了和数据增强一样的RandomCrop让验证结果随机波动。解决方法训练batch尽量大于等于8用梯度累积模拟更大batch验证时固定为CenterCrop每次epoch评估后把准确率打印出来。项目包里的Figure_CUB200.png应该就是平滑后的准确率曲线能在答辩时展示一个清晰的收敛趋势。对于稳定再现我在每个epoch结束还会保存一次最优模型if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth)5. 验证与答辩把实验结果变成报告和PPT里的说服力5.1 用Figure_CUB200.png画准确率曲线和混淆矩阵项目包里有一张Figure_CUB200.png我复现后自己重新画了一张发现关键不在于画图多花哨而在于坐标轴标注清楚。答辩老师第一眼看的往往是曲线形状和最终值所以这张图里至少要有训练准确率、验证准确率两条线横轴epoch纵轴accuracy图例放在角落不遮挡数据。代码很简单import matplotlib.pyplot as plt plt.figure(figsize(8, 5)) plt.plot(train_acc_history, labelTrain ACC) plt.plot(val_acc_history, labelVal ACC) plt.xlabel(Epoch) plt.ylabel(Accuracy (%)) plt.title(CUB-200-2011 Fine-Grained Classification) plt.legend() plt.grid(alpha0.4) plt.savefig(Figure_CUB200.png, dpi150)如果还想加混淆矩阵建议只抽10个容易混淆的鸟类类别画热力图200x200的混淆矩阵打印出来根本看不清。老师其实很吃这一套因为这说明你做了错误分析而不只是跑了个训练。5.2 报告里的实验设置表格与对比结论Final Report.pdf里应该已经有完整的实验设置我建议把它整理成一张表格写进PPT数据集划分CUB官方划分训练/测试图像数、图像尺寸、数据增强、优化器、学习率策略、训练轮数、最终准确率和推断时间。这张表既能让别人复现你的实验也是答辩时被问到“你这结果怎么来的”最省事的答案。还要写清楚迁移学习和BCNN的对比结论哪怕你只跑了其中一条另一条用参考文献的数据做对比也要说明。5.3 一个提高答辩成功率的习惯强制走一遍固定种子再重新训练我见过太多人答辩前一天改了一个数据增强参数导致最终结果变差又没时间重训只能拿着以前的结果图硬讲。从那以后我每次做大作业都会在训练脚本开头加set_seed(42)并在README.txt里记录下最终那次实验的所有超参数和当时的命令行。项目包里既然已经提供了best模型答辩现场直接加载模型跑测试集展示输出结果而不是展示训练过程既省时间又稳。把这套流程写成一个run.sh参数列表包括h5路径、batch size、epoch、学习率让每份实验都能一键复现这样就算老师当场让你换一个batch size重跑验证你也拿得出预期结论。希望帮到你。本文还有配套的精品资源点击获取