
简介面向数字图像处理课程大作业或毕业设计的学生这份资源基于CUB-200-2011鸟类数据集提供细粒度图像分类的完整高分实现方案。项目包含双线性卷积神经网络与迁移学习两种技术路线涵盖数据集解析、特征提取、模型训练与评估等环节并附源码、实验报告、答辩幻灯片和讲解文档其中四个Python脚本是核心代码且调试通过可运行三个PDF为方案说明与总结。压缩包共十四个文件另有文本与Markdown说明、一张分类效果图整体约四点七六兆字节目录按模块组织便于逐项学习对照。资源评审分数达九十八分已有超过一百人浏览学习完整呈现了从数据预处理到最终结果的高分设计流程能够帮助学习者快速掌握细粒度分类的常见做法与调优思路。1. 细粒度图像分类大作业用 CUB-200-2011 把数字图像处理课设做到 98 分细粒度图像分类Fine-Grained Image Classification是数字图像处理课程里少有的课本知识不够用的方向普通分类区分猫和狗细粒度分类要区分 200 种长得几乎一样的鸟。CUB-200-2011 是这一方向的标杆数据集包含 200 个鸟种、约 1.18 万张图片每张都带类别标签和 bounding box 标注。这篇笔记拆的是一个拿 98 分的大作业工程包里面是两条完整路线——基于迁移学习的 transfer.py 和基于双线性 CNN 的 bcnn.py外加数据工具 cub_util.py、HDF5 转换脚本 create_h5_dataset.py、最终报告 PDF 和答辩 Slides。整个项目用 Python 实现主框架是 PyTorch难度适中适合正在做数字图像处理期末大作业、毕业设计或者想用 CUB 数据练手细粒度分类的同学。我按数据怎么读 → 两条模型路线怎么跑 → 坑在哪 → 报告怎么加分的顺序来讲每一步都能直接复现。2. 数据准备与预处理cub_util.py 和 create_h5_dataset.py 的每一步2.1 CUB-200-2011 的目录结构与标注文件读取先把数据集本身讲清楚。CUB-200-2011 官方压缩包解压后是CUB_200_2011/目录下面有images/文件夹和四个关键标注文件。这四个 txt 就是整个数据集的地图读错一个后面全乱。文件内容格式示例images.txt图片路径与全局索引1 Black_Footed_Albatross_0001_796111.jpgimage_class_labels.txt图片索引对应的类别1-2001 1train_test_split.txt训练/测试划分1 训练0 测试1 1bounding_boxes.txt每张图的 bboxx, y, w, h1 68 105 190 206这个数据集最常见的坑是官方给的 train/test 划分必须原样使用不能自己重新随机划分否则和已有论文的结果没有可比性。训练集约 5994 张测试集约 5794 张类间分布基本均衡。cub_util.py 的核心职责就是把这四个 txt 一次性读进内存返回训练和测试用的图片路径与标签。# cub_util.py 中的核心读取逻辑节选 import os from PIL import Image def load_cub_meta(data_root): def read_list(path): with open(path) as f: return [line.strip() for line in f.readlines()] images read_list(os.path.join(data_root, images.txt)) labels read_list(os.path.join(data_root, image_class_labels.txt)) split read_list(os.path.join(data_root, train_test_split.txt)) # 去掉行号解析出真实内容 img_paths [line.split(. , 1)[1] for line in images] img_labels [int(line.split( )[1]) - 1 for line in labels] # 标签转 0-index is_train [int(line.split( )[1]) 1 for line in split] train_paths [p for p, t in zip(img_paths, is_train) if t] train_labels [l for l, t in zip(img_labels, is_train) if t] test_paths [p for p, t in zip(img_paths, is_train) if not t] test_labels [l for l, t in zip(img_labels, is_train) if not t] return (train_paths, train_labels), (test_paths, test_labels)这段代码有两个细节值得说明。第一images.txt每行是序号 空格 路径但路径里没有多余空格所以用split(. , 1)切掉序号最稳image_class_labels.txt里类别是 1-200训练前必须统一减 1 变成 0-199否则 PyTorch 的 CrossEntropyLoss 会越界报错。第二read_list是一次性把文件读入内存这两万行级别的 txt 完全没压力但不要在大循环里重复调用它。我一般会在加载完成后顺手打印一次类别数和样本数确认len(set(train_labels)) 200。如果发现类别数不对八成是某个 txt 切分符号写错这类低级错误越早暴露越好。2.2 图像增强与归一化参数细粒度分类里图像的局部差异极其细微——比如两种鹟莺的区别就在眼纹和翼斑上。数据增强不是可选项而是提升泛化的刚需。transfer.py 和 bcnn.py 里用的增强策略基本一致都是 PyTorch 标准套路# transfer.py 中的数据预处理部分节选 from torchvision import transforms train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.2, 0.2, 0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) test_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 的 scale 下限设成 0.7是因为 CUB 的鸟主体通常比较大过度随机裁剪会把鸟裁掉一半模型学到的就成了羽毛纹理而不是鸟的整体结构ColorJitter 的幅度控制在 0.2因为鸟类颜色本身是判别性特征抖动太大等于把标签信息洗掉。test_transform 用Resize(256) CenterCrop(224)是 ImageNet 评测的标准口径千万别用 RandomResizedCrop 去做测试否则每次评测结果都不同答辩时老师一问就露馅。还有一个容易翻车的点Normalize 的 mean/std 必须用 ImageNet 的统计值不是自己算数据集均值。因为后面要加载 ImageNet 预训练权重输入分布不一致微调效果会明显变差。2.3 HDF5 转换把一万多张图片装进一个文件第一次跑的时候直接用 PIL 在训练循环里读图结果每个 epoch 有近 6000 次小文件 IOSSD 上都要等半天。后来我才理解工程包里为什么要放 create_h5_dataset.py——把所有图片打包成一个 HDF5 文件训练时按索引随机读取IO 开销直接降一个量级。# create_h5_dataset.py 的核心逻辑节选 import h5py import numpy as np from PIL import Image from cub_util import load_cub_meta def build_h5(data_root, out_path): (train_paths, train_labels), (test_paths, test_labels) load_cub_meta(data_root) with h5py.File(out_path, w) as f: for split, paths in [(train, train_paths), (test, test_paths)]: # 预留数据集空间chunks 设为 (1, 3, 224, 224) 便于随机读取 ds f.create_dataset( split _images, shape(len(paths), 3, 224, 224), dtypenp.uint8, chunks(1, 3, 224, 224) ) for i, rel_path in enumerate(paths): img Image.open(os.path.join(data_root, images, rel_path)).convert(RGB) img img.resize((224, 224)) ds[i] np.array(img) f.create_dataset(split _labels, datanp.array(labels))这里有几个工程上的选择。chunks 设成(1, 3, 224, 224)意味着每次读取只解压一张图的块训练循环里按索引随机取数据时不会把整个文件读一遍dtype 用 uint8 而不是 float32文件体积缩小 4 倍读取时再转 tensor 做归一化。图片直接 resize 到 224×224 而不是 256是为了省空间损失一点随机裁剪的多样性实测对最终精度影响不大因为训练时还会再做一次 RandomResizedCrop。我在实际跑的时候发现HDF5 方案还有一个隐藏优势内存占用可控。直接用 torchvision 的 ImageFolder 配合 DataLoader 多进程加载内存经常飙到 10GB 以上换成 HDF5 后每个 worker 只维护一个文件句柄内存稳定在 3GB 左右。如果你的课设机器是普通笔记本这个差异体感非常明显。提示HDF5 文件是一次性构建的数据集的分类、划分固定后就不会变。如果后面调整了图像尺寸需要重新执行一次构建脚本不要想着在加载时临时改尺寸。3. 迁移学习路线transfer.py 的骨干网络、微调策略与训练调参3.1 为什么细粒度分类要选迁移学习而不是从零训练CUB-200-2011 一共才 1.18 万张图平均每类不到 60 张。在这种数据量下从零训练一个深度 CNN几乎必然过拟合模型会把训练集里的背景、光线、拍摄角度一起记下来测试集上准确率很难看。而 ImageNet 预训练模型已经学到了丰富的纹理、边缘、形状特征这些底层特征对鸟类识别同样适用。迁移学习的本质就是借用别人已经花大算力学好的特征提取器只重新学一个分类头。对数字图像处理课设来说迁移学习还有一个现实意义它把训练时间从几十个小时压到一两个小时内。用一块普通 GPUGTX 1660 级别微调 ResNet5020 个 epoch 大概 40 分钟就能跑完这在课设周期里完全能接受。概括成一句话数据量不够大时预训练权重就是最好的正则化。这也是为什么工程包里 transfer.py 是第一优先级的主线方案。有的同学会问能不能用特征提取完全冻结 backbone只训分类头代替微调可以但效果差一截。CUB 和 ImageNet 的类别差异较大鸟类的细粒度特征集中在高层语义上完全不更新 backbone 的话特征图里缺了鸟嘴、翼斑这类判别信息。折中方案就是只微调最后 1-2 个 block这是我在对比多次后确定的策略。3.2 transfer.py 的训练流程与关键参数transfer.py 的整体流程是加载预训练模型 → 替换最后一层全连接 → 冻结部分层 → 训练。我当初在这个脚本上反复调参踩了不少坑下面这段代码保留了最终验证过的配置# transfer.py 的主要模型构建逻辑节选 import torch.nn as nn from torchvision import models def build_model(num_classes200, backboneresnet50): # 加载 ImageNet 预训练权重 model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V1) # 冻结前 5 个阶段只微调 layer4 和最后的全连接层 for name, param in model.named_parameters(): if layer4 not in name and fc not in name: param.requires_grad False # 把最后的全连接换成 200 类输出 in_features model.fc.in_features model.fc nn.Sequential( nn.Dropout(0.5), nn.Linear(in_features, num_classes) ) return model关键训练参数我整理成一张表答辩时可以直接照抄进报告参数值说明backboneresnet50准确率与训练速度的折中vgg16 更慢且效果接近冻结策略冻结到 layer3微调 layer4 fc防止小数据量下底层特征被破坏优化器SGD, momentum0.9, weight_decay1e-4微调场景下 Adam 反而更容易过拟合学习率微调层 0.001fc 层 0.01分层设置学习率是微调标配batch_size328GB 显存下的安全值epoch20第 10 轮后 loss 基本进入平台期学习率衰减StepLR, step10, gamma0.1第 11 轮起 lr 降为原来的 1/10冻结策略是这段代码的核心。微调时如果全部放开1 万张图会把预训练权重冲垮只微调最后一两个 block相当于保留通用特征的同时让高层特征向鸟类数据做一次定向适配。Dropout 0.5 加在全连接前是为了压制全连接层的过拟合——这是我在 loss 曲线一直降不下去之后加上的效果立竿见影。分层学习率可以用param_groups实现把 requires_gradTrue 的参数分成两组fc 组的 lr 是 layer4 组的 10 倍。训练循环里还有一个值得注意的细节验证集评估时一定要用model.eval()配合torch.no_grad()并且关闭 Dropout否则测试准确率会偏低且不稳定。我第一次测出来 62% 以为模型坏了查了半天才发现是忘了切 eval 模式。3.3 分类头设计与评估指标资源里的报告DIP Project - Final Report.pdf对评估部分写得很细这也是拿 98 分的重要原因。分类头从nn.Linear(2048, 200)换成带 Dropout 的 Sequential测试准确率大概涨了 1.5 个百分点这个可量化的对比在报告里非常加分。评估时我用了两个指标整体准确率top-1 accuracy和 top-5 准确率。CUB-200 是均衡数据集两者差距不算大但报告里同时给出两个数字能体现你对指标口径的理解。计算方式# 评估代码同时输出 top-1 和 top-5 准确率 def evaluate(model, loader): model.eval() correct1 correct5 total 0 with torch.no_grad(): for images, labels in loader: images, labels images.cuda(), labels.cuda() outputs model(images) _, pred outputs.topk(5, 1, True, True) for i in range(labels.size(0)): if labels[i] in pred[i][:1]: correct1 1 if labels[i] in pred[i]: correct5 1 total labels.size(0) return correct1 / total, correct5 / total报告里把 top-1 和 top-5 分开写是一个很好用的防御性设计就算单类准确率被老师当场质疑top-5 命中率作为补充指标也能证明模型确实学到了有效特征而不是瞎猜。4. 双线性 CNNBCNNbcnn.py 里的细粒度特征提取原理与落地4.1 Bilinear Pooling 的核心思想细粒度分类和普通分类的本质区别在于类别间的差异集中在局部比如鸟嘴的形状、翼斑的颜色分布。普通 CNN 的全局平均池化把空间信息全压成一个向量局部细节被平均掉了。BCNNBilinear CNN的思路是用两个特征提取器分别输出特征图然后在每个空间位置做外积outer product得到二阶统计信息再池化作为图像描述。为什么外积有用两个特征图 A 和 B 在位置 (i, j) 的外积 A[i,j] ⊗ B[i,j]本质上是二阶矩矩阵它编码了这个位置的通道 a 和通道 b 是否同时激活。这种通道共现信息对纹理、局部形态的判别力是普通一阶池化不具备的。用 ImageNet 预训练的 VGG16 做骨干微调后 BCNN 在 CUB-200-2011 上能稳定做到 80% 以上的 top-1 准确率比单模型迁移学习高 3-5 个百分点。对课设来说BCNN 的意义还在于展示你不是只会调包调参而是真的理解了一篇经典论文。报告中用一两页讲清楚 bilinear pooling 的数学形式和动机是直接的加分项。尤其当老师问你为什么用双线性而不是直接把特征拼接你能回答出拼接只有一阶统计信息外积是在建模通道间的二阶依赖时这一问就已经过了。4.2 bcnn.py 的实现要点# bcnn.py 中的双线性池化实现节选 import torch import torch.nn as nn import torch.nn.functional as F from torchvision import models class BilinearPooling(nn.Module): def forward(self, x1, x2): # x1, x2: (B, C, H, W) B, C, H, W x1.size() # 展平空间维度变成 (B, C, H*W) x1 x1.view(B, C, H * W) x2 x2.view(B, C, H * W) # 每个样本做外积x1 * x2^T - (B, C, C) bilinear torch.bmm(x1, x2.transpose(1, 2)) / (H * W) # 展平 L2 归一化稳定训练 bilinear bilinear.view(B, C * C) bilinear F.normalize(bilinear, p2, dim1) return bilinear class BCNN(nn.Module): def __init__(self, num_classes200): super().__init__() vgg models.vgg16(weightsmodels.VGG16_Weights.IMAGENET1K_V1) # 取 features 部分作为共享分支 self.features vgg.features self.pool BilinearPooling() # 512*512 维特征 - 先降维 - 再分类 self.fc nn.Sequential( nn.Linear(512 * 512, 512), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(512, num_classes) ) def forward(self, x): feat self.features(x) # (B, 512, 14, 14) bilinear self.pool(feat, feat) # 共享分支自外积 return self.fc(bilinear)这段代码有两个地方是踩坑后改出来的。第一外积结果是 512×512 262144 维直接接全连接层显存会爆所以中间加了一个 512 维的降维层第二共享分支意味着两个输入是同一个特征图外积退化为自外积。理论上 A 和 B 可以不同比如一个取高层特征、一个取浅层特征但工程上共享分支参数量少一半效果几乎持平课设场景用共享分支足够。F.normalize做 L2 归一化是 bilinear pooling 的标准后处理能显著提升数值稳定性去掉它 loss 会波动很大。BilinearPooling 的 forward 里有个细节torch.bmm是批量矩阵乘法专门处理 B 个样本各自的 C×H·W 矩阵相乘输出 (B, C, C)。这个操作不能改用torch.matmul加循环否则训练速度会慢好几倍。4.3 训练代价与调参实战BCNN 的显存开销比迁移学习大很多。VGG16 本身参数就多再加上 262144 维的特征向量中间特征占用的显存大约是迁移学习路线的 2 倍。我在 8GB 显存的卡上batch_size 只能开到 16而迁移学习可以开到 32。训练 BCNN 时要注意几个具体调整调整项迁移学习BCNNbatch_size3216优化器SGDAdam二阶特征对 SGD 动量敏感初始学习率0.0010.001epoch2035-40RandomResizedCrop scale(0.7, 1.0)(0.5, 1.0)RandomResizedCrop 的 scale 下限调到 0.5是为了让模型看到更多局部细节因为 bilinear pooling 的判别力恰恰来自局部纹理的通道共现训练轮数翻倍是因为二阶特征拟合更慢20 个 epoch 跑 BCNN 大概率欠拟合。这两处不加的话BCNN 的优势根本发挥不出来。两条路线的最终结果对比在报告里有完整的表格。用一句话概括迁移学习是低成本高性价比的默认选择BCNN 是多花一倍时间换 3-5 个点准确率的进阶方案。时间充裕就把两条路线都跑通并用对比表格呈现这会成为整个大作业最扎实的章节。5. 数字图像处理大作业避坑指南环境、显存、标注、报告这些坑我全踩过5.1 现象训练 loss 从一开始就不降一直在 5.2 附近震荡原因CrossEntropyLoss 对 200 类均匀分布的初始 loss 是 ln(200) ≈ 5.30。如果 loss 稳定在 5.2 附近说明模型根本没在学最常见原因是全连接层权重初始化不当或者微调层学习率过小低于 1e-4 时特征适配速度极慢。解决把 fc 层初始化改成nn.init.kaiming_normal_(fc.weight)同时确认微调层学习率不低于 0.001。另一个排查点是检查 ColorJitter 是否过度导致输入分布和预训练分布严重错位。我最后用先跑 2 个 epoch 看 loss 是否低于 4.0作为快速自检开关不达标就先调参再上全量训练省下的时间足够多跑三四个对比实验。5.2 现象CUDA out of memorybatch_size 32 直接爆显存原因8GB 显存跑 BCNN 时262144 维特征是真实的显存杀手另一个隐藏原因是 PyTorch 默认在反向传播前会缓存所有中间激活值VGG16 的特征图特别占空间。解决BCNN 路线把 batch_size 降到 16同时打开torch.backends.cudnn.benchmark True。如果还想等效大 batch就用梯度累积循环 2 次loss.backward()再optimizer.step()等效 batch_size 32。注意梯度累积时要手动把 loss 除以累积步数否则等效学习率变大模型容易发散。5.3 现象训练集准确率很高测试集只有 60%模型过拟合严重原因CUB-200 每类训练样本不到 30 张全连接层参数量 2048×200几乎可以硬背下所有训练样本。另一个隐蔽原因是验证时没切换model.eval()Dropout 还在随机丢神经元导致测试结果偏低——这个坑我浪费了大半天。解决三层防御。第一fc 前加 Dropout(0.5)第二RandomResizedCrop 的 scale 下限从 0.7 降到 0.5让裁剪更激进等效增加样本多样性第三只微调 layer4 和 fc不放开全部层。做完这三步过拟合明显缓解。测试前打印一次model.training确认是 False就能彻底排除 eval 模式的低级失误。5.4 现象数据集路径对不上加载时报 FileNotFoundError原因官方数据集的 images.txt 序号从 1 开始但某些第三方镜像会在文件名前加前缀更隐蔽的是 Windows 上解压后路径分隔符变成反斜杠拼出来的路径在 Linux 上找不到。解决统一用os.path.join拼接路径不要手写/或\加载后打印前 5 条路径确认存在性写一个 10 行的快速校验assert all(os.path.exists(p) for p in train_paths[:10])。如果解压出来的 images 目录嵌套多了一层用os.walk重建索引不要手工改 txt。5.5 现象答辩时老师问你的准确率为什么比论文低/高答不上来原因报告里只写了最终准确率没有交代实验条件——预训练权重来源、图像分辨率、训练轮数、硬件配置。老师不是质疑结果而是要确认结果可复现。解决报告里加一节实验配置表把 backbone、预训练权重版本、batch_size、learning rate、GPU 型号、训练时长全部列成表格。如果用了 BCNN明确写出是两分支还是共享分支。答不上来时用 top-5 指标补充并说明论文是 300 epoch 多卡训练课设是 20 epoch 单卡复现差距主要来自训练预算。做完每次实验我在 README 里追加一条带日期和参数的记录答辩前把表格一整理就是现成素材。6. 让图表和报告帮你把 88 分变成 98 分可视化与可复现性最后一步6.1 用错分样本和混淆矩阵讲清楚一个改进点工程包里的 Figure_CUB200.png 就是这一节的核心素材。光写准确率 82%很单薄但如果你把测试集的错分样本按类别聚成一张网格图每张图下标注预测类和真实类报告的说服力完全不一样。我当时的做法是用sklearn.metrics.confusion_matrix生成 200×200 的混淆矩阵找出混淆最严重的 5 对类别比如两种鹰、两种鹟在报告里分析它们的外观相似性并说明这类混淆可以用 bounding box 先裁剪鸟体、排除背景干扰来缓解。这个改进点能不能落地不重要重要的是展示你有诊断能力。6.2 一份可复现的跑通清单答辩前两天我把整个工程按 README.txt 的步骤从零跑了一遍记录了每一步的耗时和环境依赖。结果显示conda 装环境约 30 分钟、数据转换约 15 分钟、迁移学习训练约 50 分钟、BCNN 训练约 2 小时、报告和 Slides 整理约 2 小时。这份清单在答辩时被老师直接翻看比任何口头解释都有效。从那以后我每次做大作业都会强制走一遍从零复现流程换一台干净机器严格按 README 操作任何一步报错就修 README直到一步不差跑通。这份资源里的脚本和 README 就是按这个标准整理的下载后跟着走环境对了基本不会翻车。希望帮到你。本文还有配套的精品资源点击获取