
简介这是一份数字图像处理期末大作业的高分完整项目包主题为图像细粒度分类面向正在完成课程设计、期末项目或毕业设计的计算机相关专业学生可解决从选题实现到报告答辩的全流程需求。资源内含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.txtCUB-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]), dtypenp.uint8, chunks(64, 3, 224, 224)) labels h5.create_dataset(labels, (len(df),), dtypenp.int64) split h5.create_dataset(split, (len(df),), dtypenp.int64) for i, row in tqdm(df.iterrows(), totallen(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/Ochunk 太大一次读取浪费内存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, cols4): 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(dropTrue) 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], fontsize8) 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 的双线性 CNN3.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_classes200, backboneresnet50, freeze_stages4): if backbone resnet50: model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V2) in_features model.fc.in_features model.fc nn.Linear(in_features, num_classes) elif backbone vgg16: model models.vgg16(weightsmodels.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(flayer{freeze_stages 1}) and not name.startswith(fc): param.requires_grad False return modelfreeze_stages 是关键参数。设成 4 表示 layer1~layer4 全部冻结只训练新加的 fc适合快速验证流程设成 3 则 layer4 参与微调精度通常能提升 2~3 个点代价是训练时间翻倍。我第一版跑的时候图省事设了 freeze_stages4测试精度只有 68% 左右后来放开 layer4 微调同样迭代数到了 78%在 CUB 上这个差距是常态不是玄学。迁移学习路线还有一个加分项用 bounding_boxes.txt 做局部裁剪。常见做法是训练时在 bbox 范围内随机裁剪测试时用 bbox 中心裁剪相当于变相做了一次注意力机制对 CUB 的精度稳定提升 2 个点以上。3.2 bcnn.py双线性池化如何捕捉细粒度差异BCNNBilinear CNN是细粒度分类的经典方法核心思想是用两个特征提取器分别提取特征再计算外积得到双线性特征。两个网络一个偏全局结构、一个偏局部纹理外积相当于把两者的响应做二阶组合能捕捉「这个位置有这种纹理、那个区域有那种颜色」这类单靠一阶特征表达不出来的联合信息。import torch import torch.nn as nn from torchvision import models class BCNN(nn.Module): def __init__(self, num_classes200): super().__init__() # 两个特征提取器去头的 ResNet18 和 VGG16 特征层 resnet models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) self.features_a nn.Sequential(*list(resnet.children())[:-2]) self.features_b models.vgg16( weightsmodels.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, p2, dim1) return self.fc(bilinear)这段代码里 f_a 和 f_b 的通道数必须一致外积维度是 C×C。我最初用 ResNet18 和 VGG16 搭配两者都是 512 通道刚好对齐如果换成 ResNet502048 通道就必须在 forward 里加 1×1 卷积降维否则 torch.bmm 直接报维度错误。符号平方根和 L2 归一化是论文验证过的标准操作不做的话精度掉 5 个点左右。这里的代码是简化版项目里 bcnn.py 的完整版本还包了 Dropout 和学习率预热实际精度更高。3.3 两套方案的精度边界什么时候选 BCNN什么时候选迁移学习两条路线在 CUB 上的表现差异明显。迁移学习跑起来快、代码短、不容易翻车正常调参精度在 78%~82%BCNN 因为双线性特征维度高512×512262144 维过拟合风险更大但配合 HDF5 预读取和数据增强精度能到 84% 以上而且双线性特征的可解释性强报告里容易画特征可视化图。对比维度迁移学习 transfer.pyBCNN bcnn.py特征维度2048ResNet50 池化后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_stages2 表示 layer1~layer2 冻结layer3、layer4 和 fc 可训练freeze_stages0 表示全部可训练。直接用 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, trainTrue, transformNone): 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(fIndex {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-4Adam 在双线性特征上更容易过拟合。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 个 epochloss 几乎不降稳定在 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-3BCNN 的收敛曲线比直接 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)), dim1) probs.append(out) return torch.mean(torch.stack(probs), dim0)最后交作业时我提交的是「迁移学习 78% BCNN 85% 多尺度测试 86%」这组数字报告里的对比表每多一行答辩时底气就多一分。做完这个项目后我对细粒度分类的理解不再是「调个预训练模型」这么粗浅数据划分对不对、标签是否对齐、增强是否破坏语义、高维特征是否过拟合任何一个环节出错精度都能从 85% 掉到 70%。从那以后我每次做分类工程都强制走一遍「先查数据划分 → 再验标签索引 → 最后才动训练参数」的流程宁可多花半小时排查也不愿在答辩现场被问住。希望帮到你祝你的大作业也能稳稳过审。本文还有配套的精品资源点击获取