简介:面向数字图像处理课程设计及细粒度图像分类入门者,提供一份评审分为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(maxsplit=1)。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_size=224): 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), dtype='uint8') grp.create_dataset('labels', shape=(len(name),), dtype='int64') 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_size=224是迁移学习最常用的输入尺寸,如果后面用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(pretrained=True) 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}, ], momentum=0.9, weight_decay=5e-4) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1)这个参数设置是这类任务的典型配置,直接能在CUB上用。我一般还会把layer4单独列出来给个小一点的学习率,因为层越深和具体任务越贴近,越容易被大学习率带偏。weight_decay=5e-4是标准L2正则,防止新分类头过拟合。StepLR每30个epoch把学习率降到原来的0.1倍,配合90个epoch总训练量,训练曲线会呈现明显的三段下降。
如果机器上没有ImageNet预训练权重,PyTorch会自动下载,这也是你第一次跑transfer.py时最大的网速瓶颈。项目包里的Transfer_Model文件夹应该就是跑通后保存下来的模型权重,这也解释了为什么这个包可以直接评估而不用重新训——你在本地随便找几张鸟图喂进去也能出预测结果。
3.2 bcnn.py:双线性CNN核心外积实现与特征维度分析
BCNN(Bilinear 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, p=2, dim=1) 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.py) | BCNN(bcnn.py) |
|---|---|---|
| 输入图像尺寸 | 224x224 | 448x448 |
| 骨干网络 | ResNet50预训练 | VGG16预训练 |
| 特征维度 | 2048 | 262144 |
| 数据增强 | 随机裁剪+翻转 | 随机裁剪+翻转 |
| 优化器 | SGD + Momentum | SGD + 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, train=True): 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(seed=42): 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 - 1,cub_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),当C=512、B=32时,光是特征就有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-3,layer4学习率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, label='Train ACC') plt.plot(val_acc_history, label='Val ACC') plt.xlabel('Epoch') plt.ylabel('Accuracy (%)') plt.title('CUB-200-2011 Fine-Grained Classification') plt.legend() plt.grid(alpha=0.4) plt.savefig('Figure_CUB200.png', dpi=150)如果还想加混淆矩阵,建议只抽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重跑验证,你也拿得出预期结论。希望帮到你。
本文还有配套的精品资源,点击获取