简介:这是一份数字图像处理期末大作业的高分完整项目包,主题为图像细粒度分类,面向正在完成课程设计、期末项目或毕业设计的计算机相关专业学生,可解决从选题实现到报告答辩的全流程需求。资源内含4个Python脚本,覆盖数据预处理、BCNN与迁移学习模型构建、训练测试等核心环节,并配有任务讲解PDF、最终报告PDF、答辩PPT与Word版项目解析文档,帮助系统理解细粒度分类的具体实现。整个压缩包约4.76MB,共14个文件,包含预训练的迁移模型、项目结构说明、图片样例及README指引,目录层次清晰,便于直接运行和二次开发。所有源码均经本地编译和严格调试,确保可复现,既可作为高分项目模板,也适合对图像分类实战感兴趣的学习者深入拆解。已有53人学习下载,方案获导师指导与评审认可,具备实际参考价值。
1. 期末大作业选型:为什么图像细粒度分类能拿 98 分
数字图像处理期末大作业最尴尬的场景,不是算法实现不了,而是你交上去的东西和上一届学长一模一样,答辩时导师问两句就露馅。我最终选的方向是图像细粒度分类,用 Python 在 CUB-200-2011 鸟类数据集上做 200 类物种识别,最终评审 98 分。这个分数不是靠堆模型堆出来的,而是因为细粒度分类天然适合大作业:它同时覆盖预处理、特征提取、分类器设计、模型对比四条主线,每个环节都有量化指标可以写进报告。资源里对应两套完整可运行的实现——迁移学习路线(transfer.py)和双线性 CNN 路线(bcnn.py),本地编译调试通过,配合报告、PPT 和讲解文档,适合正在做期末大作业的计算机相关专业学生直接落地复现。
2. CUB-200-2011 数据集与工程结构:先把标注文件读明白,再写预处理
2.1 四类标注文件:images.txt、image_class_labels.txt、train_test_split.txt 与 bounding_boxes.txt
CUB-200-2011 是细粒度分类最常用的基准数据集,11788 张鸟类图像,200 个物种。它和 MNIST、CIFAR 这类数据集最大的区别在于:标注文件不是单一 CSV,而是分散在多个 txt 里,字段用空格隔开,所有编号从 1 开始而不是从 0 开始。项目里的 Interpret_CUB_200_2011.docx 把这四类文件解释得很清楚,我复述一下关键结构。
images.txt 是图像清单,每行两列:图像编号(1~11788)和相对路径。image_class_labels.txt 每行是「图像编号 + 类别编号」,类别范围 1~200。train_test_split.txt 每行是「图像编号 + 1/0」,1 表示训练集,0 表示测试集。bounding_boxes.txt 每行是「图像编号 + x + y + width + height」,这个框在做局部裁剪增强时非常有用。
我一般会写一个统一的读取函数,把几个 txt 合并成一个 DataFrame,后续所有操作都基于这张表:
import pandas as pd def load_cub_annotations(base_dir): images = {} with open(f'{base_dir}/images.txt') as f: for line in f: idx, path = line.strip().split() images[int(idx)] = path labels = {} with open(f'{base_dir}/image_class_labels.txt') as f: for line in f: idx, label = line.strip().split() labels[int(idx)] = int(label) # 注意 1~200 split = {} with open(f'{base_dir}/train_test_split.txt') as f: for line in f: idx, is_train = line.strip().split() split[int(idx)] = int(is_train) df = pd.DataFrame({ 'image_id': list(images.keys()), 'path': [images[i] for i in images], 'label': [labels[i] for i in images], 'is_train': [split[i] for i in images], }) df['label'] = df['label'] - 1 # 转成 0~199,适配 CrossEntropyLoss return df这段代码有几个细节值得注意。label 减 1 是必须的,PyTorch 的 CrossEntropyLoss 要求类别从 0 开始,不减的话训练时 loss 会明显偏高甚至不收敛。train_test_split 里 1 和 0 的含义也容易搞反,我最初就把 1 当成测试集用,结果验证精度比训练精度还高,怎么看都不对。建议加载后先打印 df['is_train'].value_counts(),确认训练样本数接近 5794、测试样本数接近 5994 再往下走。
2.2 create_h5_dataset.py:把 11788 张图打包成单个 HDF5 文件
细粒度分类训练时如果每次从硬盘读 JPEG 再解码,瓶颈会非常明显,尤其 BCNN 这种双流网络,一张图要过两个特征提取器。create_h5_dataset.py 做的就是这件事:把全部图像预处理后写入单个 HDF5 文件,训练时直接从内存映射读取。HDF5 支持随机索引读取,不需要把 11788 张图全部加载进内存,读取速度比散装 JPEG 快一个量级。
import h5py import cv2 import numpy as np from tqdm import tqdm def create_h5_dataset(df, img_root, h5_path, target_size=(224, 224)): with h5py.File(h5_path, 'w') as h5: images = h5.create_dataset( 'images', (len(df), 3, target_size[0], target_size[1]), dtype=np.uint8, chunks=(64, 3, 224, 224)) labels = h5.create_dataset('labels', (len(df),), dtype=np.int64) split = h5.create_dataset('split', (len(df),), dtype=np.int64) for i, row in tqdm(df.iterrows(), total=len(df)): img = cv2.imread(f'{img_root}/{row.path}') img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, target_size) images[i] = img.transpose(2, 0, 1) # HWC -> CHW labels[i] = row.label split[i] = row.is_train核心参数是 target_size。CUB 图像原始尺寸基本在 500×500 以上,直接缩到 224×224 会丢失羽毛纹理等细节,而细粒度分类恰恰依赖这些细节。建议至少存一份 224×224 版本做基线实验,如果显存允许,再生成一份 336×336 版本,BCNN 精度提升明显。chunks 参数影响读取性能:chunk 太小频繁触发磁盘 I/O,chunk 太大一次读取浪费内存,64 张一个 chunk 是稳妥配置。
2.3 cub_util.py:让读取和统计代码短一半的辅助函数
cub_util.py 是公共工具模块,把跨文件复用的逻辑收拢到一起。它的定位是:除了标注读取,还负责类别名映射、按类别统计样本数、随机采样一个 batch 做可视化。这部分代码不长,但能显著减少 transfer.py 和 bcnn.py 里的重复逻辑,也让报告里的数据分布图好画很多。
import matplotlib.pyplot as plt def get_class_names(base_dir): names = {} with open(f'{base_dir}/classes.txt') as f: for line in f: idx, name = line.strip().split(' ', 1) names[int(idx)] = name return names def show_batch_sample(df, img_root, class_names, cols=4): rows = 4 fig, axes = plt.subplots(rows, cols, figsize=(12, 3 * rows)) sample_df = df.groupby('label').apply(lambda x: x.sample(1)).reset_index(drop=True) for ax, (_, row) in zip(axes.flatten(), sample_df.head(rows * cols).iterrows()): img = plt.imread(f'{img_root}/{row.path}') ax.imshow(img) ax.set_title(class_names[row.label + 1], fontsize=8) ax.axis('off') plt.tight_layout() return figshow_batch_sample 在答辩时很加分,导师一眼就能看出你知道每类样本长什么样。注意 class_names 的索引要加 1,因为 classes.txt 里的编号从 1 开始,而 DataFrame 里的 label 已经减过 1 了。这种索引偏移是小项目最容易翻车的地方,后面第 5 章我会集中排查。
3. 两条技术路线:transfer.py 的迁移学习与 bcnn.py 的双线性 CNN
3.1 transfer.py:换掉最后一层全连接,微调预训练网络
transfer.py 做的是最经典也最稳的迁移学习路线:用 ImageNet 上预训练好的 ResNet50 或 VGG16 作为特征提取器,替换最后的全连接层为 200 类输出,再分阶段微调。细粒度分类和普通分类不一样,200 个物种之间的差异集中在喙、翅膀纹理、羽色分布这些局部区域,所以冻结全部卷积层往往效果一般,至少要把最后两个 stage 放开训练。
import torch import torch.nn as nn from torchvision import models def build_transfer_model(num_classes=200, backbone='resnet50', freeze_stages=4): if backbone == 'resnet50': model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) in_features = model.fc.in_features model.fc = nn.Linear(in_features, num_classes) elif backbone == 'vgg16': model = models.vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1) in_features = model.classifier[-1].in_features model.classifier[-1] = nn.Linear(in_features, num_classes) if backbone == 'resnet50': for name, param in model.named_parameters(): if not name.startswith(f'layer{freeze_stages + 1}') and not name.startswith('fc'): param.requires_grad = False return modelfreeze_stages 是关键参数。设成 4 表示 layer1~layer4 全部冻结,只训练新加的 fc,适合快速验证流程;设成 3 则 layer4 参与微调,精度通常能提升 2~3 个点,代价是训练时间翻倍。我第一版跑的时候图省事设了 freeze_stages=4,测试精度只有 68% 左右,后来放开 layer4 微调,同样迭代数到了 78%,在 CUB 上这个差距是常态,不是玄学。
迁移学习路线还有一个加分项:用 bounding_boxes.txt 做局部裁剪。常见做法是训练时在 bbox 范围内随机裁剪,测试时用 bbox 中心裁剪,相当于变相做了一次注意力机制,对 CUB 的精度稳定提升 2 个点以上。
3.2 bcnn.py:双线性池化如何捕捉细粒度差异
BCNN(Bilinear CNN)是细粒度分类的经典方法,核心思想是用两个特征提取器分别提取特征,再计算外积得到双线性特征。两个网络一个偏全局结构、一个偏局部纹理,外积相当于把两者的响应做二阶组合,能捕捉「这个位置有这种纹理、那个区域有那种颜色」这类单靠一阶特征表达不出来的联合信息。
import torch import torch.nn as nn from torchvision import models class BCNN(nn.Module): def __init__(self, num_classes=200): super().__init__() # 两个特征提取器:去头的 ResNet18 和 VGG16 特征层 resnet = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) self.features_a = nn.Sequential(*list(resnet.children())[:-2]) self.features_b = models.vgg16( weights=models.VGG16_Weights.IMAGENET1K_V1).features self.fc = nn.Linear(512 * 512, num_classes) def forward(self, x): f_a = self.features_a(x) # (B, 512, H, W) f_b = self.features_b(x) # (B, 512, H, W) B, C, H, W = f_a.size() f_a = f_a.view(B, C, H * W) f_b = f_b.view(B, C, H * W) bilinear = torch.bmm(f_a, f_b.transpose(1, 2)) / (H * W) bilinear = bilinear.view(B, -1) # 符号平方根 + L2 归一化,BCNN 论文标准后处理 bilinear = torch.sign(bilinear) * torch.sqrt(torch.abs(bilinear) + 1e-12) bilinear = torch.nn.functional.normalize(bilinear, p=2, dim=1) return self.fc(bilinear)这段代码里 f_a 和 f_b 的通道数必须一致,外积维度是 C×C。我最初用 ResNet18 和 VGG16 搭配,两者都是 512 通道,刚好对齐;如果换成 ResNet50(2048 通道),就必须在 forward 里加 1×1 卷积降维,否则 torch.bmm 直接报维度错误。符号平方根和 L2 归一化是论文验证过的标准操作,不做的话精度掉 5 个点左右。这里的代码是简化版,项目里 bcnn.py 的完整版本还包了 Dropout 和学习率预热,实际精度更高。
3.3 两套方案的精度边界:什么时候选 BCNN,什么时候选迁移学习
两条路线在 CUB 上的表现差异明显。迁移学习跑起来快、代码短、不容易翻车,正常调参精度在 78%~82%;BCNN 因为双线性特征维度高(512×512=262144 维),过拟合风险更大,但配合 HDF5 预读取和数据增强,精度能到 84% 以上,而且双线性特征的可解释性强,报告里容易画特征可视化图。
| 对比维度 | 迁移学习 transfer.py | BCNN bcnn.py |
|---|---|---|
| 特征维度 | 2048(ResNet50 池化后) | 262144(外积后) |
| 训练时间 | 约 25 分钟/epoch,单卡 | 约 60 分钟/epoch,单卡 |
| CUB 精度 | 78%~82% | 84%~87% |
| 调参难度 | 低 | 中高,要注意过拟合 |
| 报告素材 | 数据增强对比、冻结层对比 | 双线性特征可视化、池化对比 |
如果只想要一个能交的作业,迁移学习足够;如果想冲高分,我建议两条路线都保留,报告里做一个「基线 vs BCNN」的对比。这个对比本身就是完整的实验章节,答辩时导师问"你做了什么工作",你有据可答。
4. 训练实操:把 98 分结果复现出来的关键参数与运行流程
4.1 数据划分与增强参数:train_test_split.txt 的正确用法
CUB 官方划分是 5794 张训练、5994 张测试,类别是平衡的。做数据增强时要注意策略:细粒度分类业界标准是「训练时随机裁剪 + 随机水平翻转,测试时中心裁剪」,不要引入过强的失真增强如随机擦除,除非你已经确认它对 CUB 有效。
| 参数 | 取值 | 说明 |
|---|---|---|
| 训练裁剪尺度 | 0.08~1.0 | 随机裁剪面积比例,太小会截掉整只鸟 |
| 最终分辨率 | 224×224 | 基线;BCNN 建议 336×336 |
| 水平翻转 | 0.5 概率 | 鸟类左右对称,翻转不破坏语义 |
| 测试预处理 | 中心裁剪 224×224 | 与训练分布保持一致 |
| 归一化 | ImageNet mean/std | 预训练模型必须用其统计量 |
这里有个细节:用 ImageNet 预训练权重时,归一化的 mean 和 std 不能随便换。我见过有人用自己数据集的均值归一化后加载预训练模型,第一个 epoch 的 loss 奇高,就是因为分布不匹配。
4.2 迁移学习训练:冻结、微调、验证三个阶段
迁移学习训练我拆成三个阶段:第一阶段只训练新加的 fc 层,所有卷积层冻结,跑 10 个 epoch;第二阶段放开最后两个 stage 的卷积层,用更小的学习率微调 15 个 epoch;第三阶段用最小心率微调整个网络 5 个 epoch,同时做早停。
# 阶段一:只训练分类头 python transfer.py --mode classifier_only --lr 1e-3 --epochs 10 --freeze_stages 4 # 阶段二:微调高阶段卷积层 python transfer.py --mode finetune --lr 1e-4 --epochs 15 --freeze_stages 2 # 阶段三:全网络微调 python transfer.py --mode full_finetune --lr 1e-5 --epochs 5 --freeze_stages 0三个学习率依次递减 10 倍,是微调预训练模型最常见的策略。注意第二阶段和第三阶段的区别:freeze_stages=2 表示 layer1~layer2 冻结,layer3、layer4 和 fc 可训练;freeze_stages=0 表示全部可训练。直接用 1e-4 从头微调整个网络往往导致灾难性遗忘,预训练学到的通用特征全被覆盖。我在 transfer.py 里加了 checkpoint 保存,每个阶段结束存一个 best_model.pth,万一第三阶段跑崩还能回退到第二阶段,这是大作业阶段最需要的后悔药。
4.3 BCNN 训练:从 HDF5 读取到预测输出的完整链路
BCNN 训练时数据读取建议走 HDF5 内存映射,散装 JPEG 解码在双流网络下会成为明显瓶颈。读入后要实时做增强,因为 HDF5 里存的是预处理后的静态图像。
import h5py import torch from torch.utils.data import Dataset class CubHD5Dataset(Dataset): def __init__(self, h5_path, train=True, transform=None): self.h5 = h5py.File(h5_path, 'r') self.images = self.h5['images'] self.labels = self.h5['labels'] self.splits = self.h5['split'] self.is_train = train self.transform = transform def __len__(self): return len(self.labels) def __getitem__(self, idx): if self.is_train and not self.splits[idx]: raise IndexError(f'Index {idx} is test sample') img = torch.from_numpy(self.images[idx]).float() / 255.0 label = int(self.labels[idx]) img = img.permute(1, 2, 0).numpy() # CHW -> HWC if self.transform: img = self.transform(img) return img, label这个 Dataset 类有个小坑:HDF5 文件在进程结束后要确保关闭,否则下次打开会报锁错误。我一般在训练主函数里用 with h5py.File(...) as h5 包住整个 epoch 循环,而不是在 Dataset 里长期持有句柄。is_train 检查那个 raise 是我后来加的防御逻辑,因为调试时发现索引错位会把测试图混进训练集,精度虚高到 92%,答辩一查数据泄露直接露馅。
BCNN 训练超参通常比迁移学习保守:batch size 建议 32 以下,双线性特征层显存占用接近普通网络 2 倍;学习率用 1e-3 预热 5 个 epoch 再切到 1e-4;优化器用 SGD + momentum 0.9 + weight decay 1e-4,Adam 在双线性特征上更容易过拟合。
5. 避坑与排查:细粒度分类工程里最容易翻车的五个问题
5.1 训练精度虚高到 92%,查了半天是数据泄露
现象:迁移学习跑到第 10 个 epoch,训练精度 92%,测试精度 71%,两者差距大到不合理。
原因:train_test_split.txt 的划分没处理好,一部分测试图像通过 Dataset 索引错位混进了训练集。CUB 图像编号和数组下标不一定一一对应,直接用 1~11788 的连续索引去取 split 标志,只要有一处排序不对就会错位。
解决:把 images.txt、image_class_labels.txt、train_test_split.txt 三个文件先合并成以 image_id 为主键的 DataFrame,按 image_id 排序后再传给 Dataset。任何时刻都不要直接凭数组下标访问 split。我在 cub_util.py 的 load_cub_annotations 函数里强制做了这一步,并在训练脚本开头用 assert 校验 df.label.min() == 0 和 df.label.max() == 199。
5.2 HDF5 文件打开报锁错误,第二次跑就卡死
现象:第二次运行 create_h5_dataset.py 时,打开已有 HDF5 文件报「unable to synchronously open file」,程序卡死不退出。
原因:第一次创建数据集时进程异常退出,HDF5 文件的写锁没有正常释放。Windows 上尤其常见,即使进程消失了,锁文件还残留。
解决:检查任务管理器,杀掉残留的 python 进程,然后删除 h5_path 同目录下的 .lock 文件。如果确信数据没写完,直接删掉重建。我后来在 create_h5_dataset.py 里改用 with h5py.File(...) 语法确保句柄一定关闭,异常路径也覆盖。
5.3 loss 停在 5.3 附近不动,类别索引偏移的典型表现
现象:BCNN 训练了 3 个 epoch,loss 几乎不降,稳定在 5.3 附近,正好是 log(200) 的值。
原因:CUB 的类别编号是 1~200,直接喂给 CrossEntropyLoss,而 PyTorch 要求类别索引从 0 开始。模型输出和标签完全错位,loss 就停在均匀分布的熵值附近。
解决:在 load_cub_annotations 里对 label 统一减 1,并在训练脚本开头断言 min 是 0、max 是 199。这种防御性编程在小项目里性价比很高,一次断言能省半天排查时间。
5.4 torch.bmm 报 size mismatch,两个分支通道没对齐
现象:跑 bcnn.py 的 forward 时报错,torch.bmm(f_a, f_b.transpose(1, 2)) 维度不匹配。
原因:两个特征提取器输出通道不一致。比如 ResNet50 输出 2048 通道,VGG16 输出 512 通道,外积第二个维度对不上。
解决:两个分支汇合处插入 1×1 卷积把通道对齐,或者直接选通道数一致的骨干网络。项目里 bcnn.py 默认用 ResNet18 和 VGG16,输出都是 512 通道,省掉了这个麻烦。如果你换骨干,一定先打印两个 feature map 的 shape 再往后写。
5.5 微调阶段精度反而下降,学习率阶梯没做对
现象:第二阶段直接全网络微调,学习率还是 1e-3,训练到第 4 个 epoch 测试精度比阶段一结束时掉了 5 个点。
原因:预训练模型全网络微调需要更小的学习率。1e-3 对 fc 层合适,对卷积层来说太大,等价于在 ImageNet 预训练权重上做随机扰动。
解决:每个阶段学习率至少降 10 倍,且卷积层的 learning rate multiplier 设为 fc 层的 0.1 倍。在 PyTorch 里就是为不同参数组配置不同的 lr 属性,param_group 分开管理卷积层和分类头。
6. 进阶调整:把 98 分往上再推一档的四个细节
如果时间和显存都够,我建议在基线之上做四个调整,它们的收益在 CUB 上是被反复验证过的。
第一个是 bbox 引导的预处理。CUB 自带 bounding_boxes.txt,把它当作免费的弱注意力信号:训练时在 bbox 区域内做随机裁剪,测试时用 bbox 中心裁剪,比全局随机裁剪稳定提升 2~3 个点。第二个是 BCNN 的池化变体:在双线性池化后接 Dropout(0.5),能显著抑制高维特征过拟合。第三个是学习率暖启动:前 5 个 epoch 用 1e-4 预热,第 6 个 epoch 切到 1e-3,BCNN 的收敛曲线比直接 1e-3 平滑很多,loss 底部更低。第四个是多尺度测试:把同一张图缩到 224、256、336 三个分辨率分别推理,对 softmax 输出取平均,精度再涨 1~2 个点,代价是测试时间变 3 倍,但大作业只看验证集结果,这个交换划算。
def predict_multi_scale(model, img, scales=[224, 256, 336]): model.eval() probs = [] with torch.no_grad(): for s in scales: resized = torchvision.transforms.Resize((s, s))(img) out = torch.softmax(model(resized.unsqueeze(0)), dim=1) probs.append(out) return torch.mean(torch.stack(probs), dim=0)最后交作业时,我提交的是「迁移学习 78% + BCNN 85% + 多尺度测试 86%」这组数字,报告里的对比表每多一行,答辩时底气就多一分。做完这个项目后,我对细粒度分类的理解不再是「调个预训练模型」这么粗浅:数据划分对不对、标签是否对齐、增强是否破坏语义、高维特征是否过拟合,任何一个环节出错,精度都能从 85% 掉到 70%。从那以后我每次做分类工程,都强制走一遍「先查数据划分 → 再验标签索引 → 最后才动训练参数」的流程,宁可多花半小时排查,也不愿在答辩现场被问住。希望帮到你,祝你的大作业也能稳稳过审。
本文还有配套的精品资源,点击获取