简介面向数字图像处理课程实训这份资源提供了基于CUB-200-2011数据集的细粒度图像分类完整实现个人期末97分方案可直接运行并复现适合计算机相关专业学生及需要实战图像分类的学习者。压缩包共14个文件大小4.76MB包含4个Python源码BCNN双线性网络、迁移学习、数据处理脚本、3个PDF项目报告、细分类讲解、作业要求、PPTX汇报幻灯片、DOCX说明文档以及模型文件从代码、文档到展示材料一应俱全结构清晰。目前已有104人学习。源码覆盖从数据预处理、特征提取到模型训练与评估的完整流程并给出优化调参思路配套的结课报告与答辩PPT可帮助快速理解CUB-200-2011细粒度分类的技术要点对完成课程设计或冲击高分很有参考价值。此外数据集说明和README有助于快速上手。1. 细粒度图像分类实训CUB-200-2011 数据集与 95 分目标的拆解思路数字图像处理课设里如果抽到“细粒度图像分类使用 CUB-200-2011 数据集目标 95 分以上”那第一反应很容易是“把模型调猛一点”。但我拆完这份拿到 97 分的作业包之后结论完全不同它赢在数据读取规范、两条模型路线都能复现、报告和答辩材料完整而不是靠某一个模型刷到了多离谱的准确率。CUB-200-2011 数据集是细粒度分类任务的标准基准集包含 200 种鸟类共 11788 张图片每张图都有类别标签、边界框和属性标注。这份资源适合两类人一是被这门课大作业卡住、需要参考全套实现的学生二是想完整走一遍细粒度分类流程、同时对比迁移学习与双线性 CNN 的实践者。2. 数据与预处理读懂 CUB-200-2011 的标注体系把 cub_util.py 用对整个打包能不能跑通第一步取决于cub_util.py有没有把 CUB-200-2011 的标注文件正确读出来。我在拆这个项目时发现很多第一次接触的人卡在“数据集明明解压了训练 loss 却完全不下降”问题往往不出在模型上而是出在标的没有对齐。2.1 CUB-200-2011 的数据组织方式四份 txt 构成的标注体系CUB-200-2011 的发布形式不是一张现成的大表格而是把图像路径、类别、边界框和训练测试划分分散在四个 txt 文件里。解压后能看到images/001.Black_footed_Albatross/这样的按“编号.类名”组织的目录而真正驱动训练的是下面四份文件文件每行内容关键点images.txt图片全局序号 相对路径序号从 1 到 11788全数据集唯一image_class_labels.txt图片全局序号 类别号类别号从 1 到 200不是从 0 开始bounding_boxes.txt图片全局序号 x y w h坐标是原图上的 float 像素值train_test_split.txt图片全局序号 0/11 表示训练集0 表示测试集这里有个网上资料经常误导人的说法有些介绍写“每类只有 10 张图”那是把早期 CUB-200 的旧描述套到了 2011 版上。CUB-200-2011 的完整图像量是 11788 张官方划分后训练集 5994 张、测试集 5794 张平均每类约 59 张图约 30 张训练、29 张测试。另外要注意图片全局序号和类别序号是两个完全不同的东西前者用于对齐四个文件里的同一张图后者才是训练标签。写代码时千万别把行号当成标签用。2.2 cub_util.py 的读取逻辑按图片全局序号把四个文件对齐我一般会把数据读取拆成一个加载器类把四份 txt 一次性读进字典再按图片序号合并。这是cub_util.py里最常见的写法也最不容易出错import os class CUBLoader(object): 读取 CUB-200-2011 的四份标注文件按图片 id 对齐。 def __init__(self, root): self.root root self.paths, self.labels, self.bboxes, self.is_train self._parse() def _parse(self): with open(os.path.join(self.root, images.txt)) as f: paths {} for line in f: img_id, rel_path line.strip().split() paths[int(img_id)] os.path.join(self.root, rel_path) with open(os.path.join(self.root, image_class_labels.txt)) as f: labels {} for line in f: img_id, label line.strip().split() labels[int(img_id)] int(label) - 1 # CUB 类别号从 1 开始 with open(os.path.join(self.root, bounding_boxes.txt)) as f: bboxes {} for line in f: img_id, x, y, w, h line.strip().split() bboxes[int(img_id)] [float(x), float(y), float(w), float(h)] with open(os.path.join(self.root, train_test_split.txt)) as f: is_train {} for line in f: img_id, flag line.strip().split() is_train[int(img_id)] int(flag) 1 # 四个文件行数必须完全一致这是最基础的数据对账 assert len(paths) len(labels) len(bboxes) len(is_train) return paths, labels, bboxes, is_train逻辑说明四个 txt 文件都以图片全局序号为主键分别读成四个字典后同一张图的路径、标签、边界框、训练标记就通过这个序号关联起来了。代码块最后的assert是在做数据一致性校验如果某个 txt 文件缺失行或缺列这一步会直接报错而不是等训练到一半才暴露。参数说明int(label) - 1是这一节里最容易被忽略的一行。CUB 的类别号是 1200而 PyTorch 的CrossEntropyLoss要求标签是 0199 的连续整数少了这个-1模型会把第 200 类当成一个本来不存在的额外类准确率直接崩掉。2.3 预处理与数据增强先裁边界框再谈随机裁剪和归一化CUB 鸟类图像有大量背景干扰直接把整张图丢给模型算力会浪费在找鸟上。但课程作业里不要求每次都从原图整图训练比较常用的做法是先按 bbox 裁剪出鸟的主体区域再统一缩放到模型输入尺寸。我一般会给 bbox 留 1.11.2 倍的外扩而不是死贴标注框因为这个框有时候会裁掉鸟尾或脚丢失判别信息。from torchvision import transforms normalize transforms.Normalize( mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225] ) train_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale(0.6, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.ToTensor(), normalize, ]) val_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), normalize, ])逻辑说明Resize 先把图像统一到 256×256训练时用RandomResizedCrop随机裁出 224×224相当于做了简单的尺度扰动能让模型对鸟的大小不那么敏感验证阶段固定用中心裁剪保证每次预测的是同一块区域指标才可复现。参数说明均值方差用的是 ImageNet 预训练统计值如果迁移学习的主干是 ResNet50 或 VGG16这套参数是标准配置。注意训练集和验证集不要用同一套随机增强否则验证结果会虚高。另外一个隐藏坑是操作顺序如果先对整图做 Resize 再按原 bbox 坐标裁剪坐标就全错位了。正确做法是先按原图 bbox 裁剪并外扩然后再 Resize。3. 迁移学习路线基于预训练 ResNet50 的微调与训练脚本解析这份打包的Transfer_Learning目录里核心脚本是transfer.py走的是一条非常标准的迁移学习路线。我在自己的实训项目里也常这么干先冻结主干只训分类头再解冻最后一部分卷积层做微调。比起一上来就全量训练这个流程更稳。3.1 选型理由为什么细粒度分类不推荐从零训练CUB-200-2011 总共只有 11788 张图训练集不到 6000 张摊到 200 类里每类约 30 张。这种数据量下从零开始训练一个深度卷积网络模型很容易把训练集背下来测试集上却一塌糊涂。另一个原因是细粒度分类本身难很多鸟种之间差异非常细微可能只是头部颜色或翅膀花纹不同模型需要很强的底层特征表达能力而通用预训练模型已经在 ImageNet 上学到了丰富的边缘、纹理和颜色特征迁移过来做鸟类分类是性价比最高的选择。为什么选 ResNet50 而不是 VGG16 或 ResNet18VGG16 参数量大、训练慢在 CUB 这种规模的数据集上收益有限ResNet18 表达能力稍弱细粒度任务上容易欠拟合。ResNet50 处于平衡点预训练权重成熟、PyTorch 官方内置、调参资料多做课程大作业时不容易“翻车”。3.2 transfer.py 核心实现替换分类头与分阶段冻结import torch.nn as nn import torchvision.models as models model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V1) in_features model.fc.in_features model.fc nn.Linear(in_features, 200) # 替换成 200 类分类头 # 阶段一冻结主干只训练新增的分类头 for param in model.parameters(): param.requires_grad False for param in model.fc.parameters(): param.requires_grad True逻辑说明in_features从原模型fc层动态读取得到的是 ResNet50 最后一个池化层输出的特征维度 2048这样替换分类头时不需要硬编码。先冻结全部主干参数只用反向传播更新最后的全连接层。参数说明pretrained在torchvision新版本里推荐用weights参数显式指定旧写法pretrainedTrue在新版本会告警。model.fc nn.Linear(in_features, 200)这行把原来 ImageNet 的 1000 类分类头换成了 CUB 的 200 类分类头。阶段一跑几个 epoch 之后再解冻layer4和fc用更小的学习率微调# 阶段二解冻 layer4 和 fc用低学习率微调 for name, param in model.named_parameters(): if layer4 in name or fc in name: param.requires_grad True optimizer torch.optim.SGD( [p for p in model.parameters() if p.requires_grad], lr1e-4, momentum0.9, weight_decay1e-4, )逻辑说明分阶段训练的原因是让分类头先收敛再让主干后半部分适应鸟类数据。直接全量微调时随机初始化的分类头梯度会很大容易把预训练权重冲坏。参数说明lr1e-4是阶段二的常见取值比阶段一的1e-3低一个量级。weight_decay1e-4做正则化缓解 CUB 训练集太小带来的过拟合。3.3 训练参数与超参数设置让 transfer.py 跑得稳训练循环本身不需要写得很复杂关键是超参数要和数据量匹配。下面是一套我在 CUB-200-2011 上常用的参数组合参数阶段一阶段二batch size6432初始学习率1e-31e-4优化器SGD momentum0.9SGD momentum0.9epochs20302040损失函数CrossEntropyLossCrossEntropyLoss实际跑的时候我一般每个 epoch 存一次验证集 top-1 准确率最高的权重而不是按最后一个 epoch 收尾。对 CUB 这种小数据集不需要在transfer.py里堆太复杂的训练技巧PyTorch 自带的ReduceLROnPlateau或手动在最后 10 个 epoch 把学习率降到1e-5就够了。如果训练 loss 迟迟不降先从数据读取和标签偏移排查不要急着换模型。4. 双线性 CNN 路线B-CNN 的原理、h5 缓存与训练细节BCNN目录是这份资源的第二条路线也是细粒度分类里很有代表性的一种模型结构。它和普通 CNN 的最大区别在于不是简单地在最后一层做全局池化而是用两个特征提取器对同一张图提取两组特征再通过外积捕捉“特征通道之间的联合响应”。4.1 B-CNN 的核心原理两个特征提取器与外积池化双线性 CNN 最初是 2015 年提出的细粒度分类方案核心思路不复杂一张图经过两个特征提取网络分别得到特征图 F_A 和 F_B尺寸为 H×W×D_A 和 H×W×D_B。在每一个空间位置上把 F_A 的通道描述子和 F_B 的通道描述子做外积得到一个 D_A×D_B 的双线性矩阵。当两个网络都用 VGG16 时D_A D_B 512展开后就是 262144 维的特征向量。这个向量记录的是“同一位置、不同通道同时被激活的强度”。普通 CNN 最后一层卷积只能给出局部区域内有某种纹理或颜色的响应而 B-CNN 可以捕捉两种属性在同一位置共同出现的关系。比如区分两种鸟类时单看“翅膀有白色条纹”不够还要同时看“翅膀白色条纹 头部黑色”这种成对特征正是细粒度分类最需要的判别信息。这也是为什么 B-CNN 在 CUB 这类任务上比直接叠加深层网络更有代表性。4.2 bcnn.py 关键实现VGG 特征层复用与归一化下面是一份简化但可运行的 B-CNN 实现和资源里bcnn.py的核心结构一致import torch import torch.nn as nn import torchvision.models as models class BCNN(nn.Module): def __init__(self, num_classes200): super(BCNN, self).__init__() vgg models.vgg16(pretrainedTrue).features # 去掉最后一个 MaxPool保留 conv5 输出的 512 通道特征 self.features nn.Sequential(*list(vgg.children())[:-1]) self.fc nn.Linear(512 * 512, num_classes) def forward(self, x): f self.features(x) # [B, 512, H, W] b torch.einsum(bik,bjk-bij, f, f) # 双线性外积 b b / (f.size(2) * f.size(3)) # 均值池化 b b.view(x.size(0), -1) # [B, 512*512] b torch.sqrt(b 1e-12) # 符号平方根 b nn.functional.normalize(b, p2, dim1) # L2 归一化 return self.fc(b)逻辑说明self.features取 VGG16 的卷积部分并去掉最后一个 MaxPool让输出特征保持一定空间分辨率。torch.einsum(bik,bjk-bij, f, f)这一步是关键对每个 batch 内的样本把 H×W 空间位置压缩掉得到 512×512 的双线性矩阵矩阵里每个元素是两组通道在同一位置响应的乘积之和。除以空间尺寸相当于平均池化。展平后做“平方根 L2 归一化”这是 B-CNN 原论文中验证过有效的标准化步骤。参数说明512 * 512 262144是全连接层的输入维度。这个维度的代价很高整层全连接参数有 262144×200 约 5240 万个权重比 VGG16 主干本身还要重训练时对显存和内存的压力非常大。原论文里完整版本是两个独立的 VGG 网络提取两组特征实操里常用同一个 VGG 做自外积也就是这里展示的写法效果已经足够支撑课程项目。4.3 create_h5_dataset.py为什么先把数据缓存成 h5 再训练跑 B-CNN 时训练循环里每一轮前向要算一次 262144 维特征如果数据加载还要反复从磁盘读小尺寸 JPG、解码、缩放训练速度会非常难看。资源里的create_h5_dataset.py解决的就是这个问题。import h5py import numpy as np # 假设 preprocessed_images 是经过预处理后的 uint8 数组 with h5py.File(cub_train.h5, w) as h5: h5.create_dataset(images, datanp.uint8(preprocessed_images)) h5.create_dataset(labels, datanp.int64(labels))逻辑说明h5 文件把多张小 JPG 合并成一个二进制大文件读取时按索引直接切片省掉了逐个解压和重新缩放的开销。对 CUB 这种上万张图的规模能明显减少每个 epoch 的等待时间。参数说明写入时把图像转成uint8是为了控制磁盘占用四万多张 224×224×3 的图像数据大概在 12GB 量级如果转成 float32 会直接翻到 4 倍以上。训练脚本里读取 h5 时再转 float 并做归一化。如果你只有 CPU 或普通学生机h5 缓存几乎是必选项否则 B-CNN 一次训练可能要跑十几个小时。5. 避坑清单CUB-200-2011 实训里最常见的五个翻车点把数据读取、迁移学习、B-CNN 三块串起来后真正耗时间的往往不是模型结构而是一些看起来不起眼的小问题。这里列五个我在拆包复现过程中最常遇到的坑按“现象 → 原因 → 解决”写清楚。5.1 标签偏移loss 先降后卡死准确率一直上不去现象训练 loss 前几个 epoch 下降正常之后徘徊不动验证准确率明显低于预期甚至多个类别基本不预测。原因CUB 的类别号是从 1 到 200而 PyTorch 的CrossEntropyLoss要求类别从 0 开始连续编号。代码里如果直接读原始类别号模型实际上是 201 个输出头去拟合 200 个标签第 200 类会变成一个“幽灵类”每个 batch 都在给错误位置计算梯度。解决在cub_util.py读取image_class_labels.txt时统一执行int(label) - 1并在构造 Dataset 后打印前 10 个标签做人工检查确认范围在 0199 之间。5.2 训练集测试集划分不当自己随机划分会虚高现象自己按 8:2 随机划分训练测试验证准确率能冲到 90% 以上换官方划分就掉一截。原因CUB 里有大量同一只鸟的多张照片。如果完全随机切分同一只鸟的训练图和测试图会同时出现在两侧模型学到的是“认具体个体”而不是“认鸟种”。细粒度分类的标准评测必须用官方train_test_split.txt否则结果没有可比性答辩时也经不起追问。解决只用官方划分读取train_test_split.txt里的 0/1 标记筛选数据。这也是资源里 README 反复强调的点。5.3 B-CNN 显存和内存同时爆炸特征维度太高现象bcnn.py一跑就CUDA out of memory或者 CPU 内存峰值飙升后被系统杀掉。原因B-CNN 的 262144 维特征向量只是单个样本的量级batch size 设为 32 时连接分类器的中间特征就有 32×262144 个浮点数全连接层权重另有约 5240 万参数训练中间变量叠加很容易超过普通显卡的显存上限。解决batch size 降到 8 或 16条件允许时用混合精度训练如果显卡实在不够就先用create_h5_dataset.py把特征提取出来后缓存再单独训练分类头把显存压力转移到磁盘空间上。5.4 bbox 裁剪过猛或外扩不足鸟的部位被裁掉现象用了 bbox 裁剪后验证准确率反而比整图训练低了一点。原因CUB 的标注框是手工标出的鸟主体区域有时贴得非常紧鸟尾羽、长脚、嘴尖都可能落在框外另一个问题是 bbox 坐标没有做边界限制裁剪越界后 PIL 会补出黑边。解决裁剪后按 1.11.2 倍外扩坐标clamp在图像尺寸范围内并保留一组整图训练的对比结果做分析。报告中写明“bbox 裁剪 外扩”这个实验维度得分点比单纯调高准确率更扎实。5.5 数据 IO 太慢GPU 利用率持续偏低现象每个 epoch 的大部分时间都卡在数据加载上GPU 利用率只有 30%50%训练总耗时被拉长一倍以上。原因CUB 原始图片分散在上万个目录里每张图都是独立 JPG遍历文件、解码、缩放全在 CPU 上排队。尤其是 B-CNN 这种单步计算重的模型数据供给一旦跟不上硬件就空转。解决优先用create_h5_dataset.py生成 h5 缓存训练时顺序读取其次把DataLoader的num_workers调到 4 以上并在 PyTorch 2.x 里设置persistent_workersTrue减少重复创建进程的开销。6. 从跑通到 97 分验证指标、可视化与提交前的自查习惯大作业拿 97 分不是模型训练完就自动拿到的后面这十分钟的验证和整理才是拉开差距的地方。6.1 先看混淆矩阵不看单点准确率我每次复现完这套项目第一件事不是看 overall accuracy而是把混淆矩阵和 per-class accuracy 打出来。CUB 有 200 个类别绝大概率会把“黑脚信天翁”和“短尾信天翁”这种外观极像的类别搞混模型真正能区分哪几类、最容易错哪几类远比一个总数字更能说明问题。可视化时不要硬画 200×200 的大热图那样什么都看不清我一般只列 top-10 最易混的类别对再配两张错误样本图报告里写一段分析就非常扎实。6.2 把两条路线整理成对比实验这份资源里已经包含DIP Project - Final Report.pdf和DIP Project - Slides.pptx结构上可以参照一个很稳的框架课程要求与数据说明、迁移学习路线、B-CNN 路线、两组实验对比、失败案例与改进方向。对比表里放训练耗时、参数量、验证准确率、内存占用几个维度结论写“迁移学习适合快速 baselineB-CNN 对局部判别特征更敏感但训练代价高”这个结论本身也比“哪种准确率高”更有课程深度。6.3 提交前按清单过一遍我最后做一次自查通常按这个顺序检查数据集路径与 README 描述一致、cub_util.py里标签偏移是否正确、训练测试是否用了官方划分、所有代码是否是相对路径能直接换机器跑、报告里的数字能不能在本地复现。还有一个容易被忽略的细节答辩演示前把transfer.py重新完整跑一次确保不是靠缓存的旧权重才跑出结果。从那以后我每次做细粒度分类的课设都会强制自己在写模型之前先把数据读取这条链路完整跑一遍确认标签和划分没问题再开始调网络。经验是模型只是项目的最后一块拼图数据管道的正确性才决定大作业能不能守住分数。希望帮到你。本文还有配套的精品资源点击获取