
简介北京大学数字图像处理课程大作业的完整工程包以CUB-200-2011鸟类数据集为对象解决图像细粒度分类问题。方案基于Python采用双线性卷积神经网络与迁移学习两条技术路线涵盖数据集构建、模型训练、特征提取与精度评估等环节适合高校实验课、毕业设计以及计算机视觉入门者参考借鉴。压缩包共13个文件总体积约4.76MB。其中4个Python脚本分别负责HDF5数据转换、BCNN模型实现、CUB数据集工具函数和迁移学习训练3个PDF包含最终报告、细分类讲解和作业要求2个TXT为说明文档另有PPTX汇报、DOCX解读、结果图及预训练权重目录按模块组织便于对照学习。资源已有974人学习/下载。通过完整材料可掌握细粒度图像分类的经典思路、CUB-200-2011数据的解析方法以及PyTorch实现细节还能根据最终报告和讲解幻灯片理解两种方案的差异为完成类似大作业或进一步研究提供可复用的起点。1. 图像细粒度分类与CUB-200-2011先搞清楚难度再动手图像细粒度分类是数字图像处理里最常让人误判难度的一个方向普通分类分出鸟和猫很容易但在CUB-200-2011上要把200种鸟区分开模型准确率可能从九成直接掉到六成。北京大学数字图像处理课程这份大作业把两条最实用的路线都做了出来——双线性CNNBCNN作为主方案迁移学习作为基线对照配套CUB-200-2011数据集的解析脚本、H5数据集制作脚本、训练代码、结题报告和答辩幻灯片。新手可以直接照流程复现跑通一门图像处理实验熟手可以借它的数据处理管线快速切入细粒度分类。2. 双线性CNN的原理与选型外积为什么能抓住局部差异2.1 细粒度分类的难点整体相似差异只在局部普通分类网络VGG、ResNet把卷积输出的特征图做全局平均池化然后接全连接层。这个结构对猫 vs 狗这种大类区分很有效因为猫和狗在整体轮廓、耳朵形状、身体比例上有足够大的差异平均池化把全局信息压成一个向量丢掉位置细节也不影响判断。到了 CUB-200-2011 就麻烦了——200 种鸟每一种都有喙、翅膀、尾巴整体轮廓高度相似真正能区分物种的信息集中在局部黑脚信天翁的嘴部颜色、黄鹂的翼斑形状、燕尾鸥的尾羽分叉。这些细节在 7x7 的特征图上往往只占一两个像素全局平均一压信号就被淹没在大量无关响应里所以细粒度分类的准确率才会断崖式下跌。这也是数字图像处理教材里反复强调“特征选择决定上限”的原因。冈萨雷斯那本经典教材讲了纹理、颜色、形状特征但在深度网络时代手工特征已经很难覆盖 200 类鸟的局部差异所以需要网络自己学“哪些局部组合能区分物种”。BCNN 的思路就是把这个“组合”显式建模出来。2.2 双线性池化两个特征的外积双线性池化的数学表达非常简洁。给定一张图像 I两个特征提取器 A 和 B 分别输出特征图 f_A(I) 和 f_B(I)对每个空间位置取出该点的两个特征向量做外积再把所有位置的外积结果累加得到形状为 C_A × C_B 的双线性特征。外积的维度是 512×512262144这个向量经过 sqrt 归一化和 L2 归一化后接全连接分类器。注意“外积”不是逐元素相乘而是通道之间的两两组合。可以把两个分支理解成两个人一个人观察“喙的颜色”另一个人观察“翅膀纹理”外积记录的是“喙是橙色”和“翅膀有白斑”同时出现这种组合。一阶特征只能分别告诉你有橙色或白斑二阶组合关系才能刻画细到物种级别的模式。这也是 BCNN 在细粒度分类上比普通 CNN 多出来的表达能力。import torch import torch.nn as nn import torch.nn.functional as F import torchvision.models as models class BCNN(nn.Module): def __init__(self, num_classes200): super().__init__() # 常见做法两个分支都取 VGG16 的卷积部分不共享权重 self.features_a models.vgg16(pretrainedTrue).features self.features_b models.vgg16(pretrainedTrue).features # 512 是 VGG16 最后一层卷积的输出通道数 self.fc nn.Linear(512 * 512, num_classes) def forward(self, x): xa self.features_a(x) # (B, 512, 7, 7) xb self.features_b(x) # (B, 512, 7, 7) B, C, H, W xa.size() xa xa.view(B, C, H * W) xb xb.view(B, C, H * W) # 矩阵乘法等价于所有空间位置外积的累加 bilinear torch.bmm(xa, xb.transpose(1, 2)) # (B, 512, 512) bilinear bilinear.view(B, C * C) # sqrt 归一化加上 L2 归一化是原论文验证过的稳定做法 bilinear torch.sqrt(bilinear 1e-12) bilinear F.normalize(bilinear, p2, dim1) return self.fc(bilinear)这段代码里的 torch.bmm 是关键。它一次完成所有空间位置的外积累加避免了显式写循环遍历 49 个位置。1e-12 防止某个位置全零导致根号下为 0L2 归一化让 262144 维向量进入全连接时数值范围稳定。features_a 和 features_b 分别加载一遍 VGG16 预训练权重参数不共享两个分支在训练中会逐渐分化——一个更关注颜色一个更关注纹理。2.3 分支结构选型共享权重还是不共享BCNN 有两种常见变体。第一种是两个分支结构相同但不共享权重表达能力更强适合有 GPU 的场景第二种是共享同一个特征提取器参数省一半精度略降适合显存紧张的环境。在这份大作业的 bcnn.py 里我按常见做法拆出来的是不共享权重版本因为 CUB-200-2011 训练图只有不到 6000 张VGG16 预训练初始化已经提供了足够的低层语义不共享权重相当于给模型两组可互补的视角。选择分支网络时还有一个边界条件要留意全连接输入维度必须写成 C×CC 是所选分支最后一层卷积的输出通道数。如果换成 ResNet输出通道变成 2048全连接维度就是 2048×2048显存直接爆炸。所以这套资源里用 VGG16 是合理的512 的输出维度在外积计算量上处于可接受范围。细分类讲解.pdf 里对这部分有图示把外积和反向传播画得很清楚配合报告一起看会更直观。2.4 为什么还要迁移学习数据量撑不起从头训练CUB-200-2011 一共 11788 张图官方划分后训练集大约 5994 张平均每种鸟只有 30 张训练样本。拿这个数据量从头训一个 VGG16连收敛都困难更别说 BCNN 这种参数量更大的模型。迁移学习的思路是用 ImageNet 上预训练好的权重做初始化冻结底层卷积只训练最后的分类层等分类器稳定后再解冻微调。BCNN 的两个分支同样用预训练权重初始化本质上也属于迁移学习的范畴。选择策略很简单算力有限、只想快速出一个可对比的基线跑 transfer.py有 GPU、想冲报告里的精度上限跑 bcnn.py。这份资源里的 DIP Project - Final Report.pdf 给出了两条路线的对比Figure_CUB200.png 就是两条学习曲线的可视化。先跑通迁移学习再切到 BCNN踩坑成本会低很多。3. 把CUB-200-2011转成H5解析脚本与标签对齐3.1 数据集文件结构四个txt先摸清楚CUB-200-2011 解压后不是一张大表而是分散在多个 txt 里的元数据。第一次接触的人容易只盯着 images 文件夹看忽略旁边那几个 txt结果训练时标签对不上。先列一下这几个文件的角色文件内容示例作用images.txt1 001.Black_footed_Albatross/1.jpg图片 id 与相对路径的映射image_class_labels.txt1 1图片 id 对应的类别 id1-200train_test_split.txt1 1图片 id 对应的划分标记1 训练 0 测试bounding_boxes.txt1 10 20 100 200图片 id 对应的标注框位置这四个文件都以图片 id 作为外键。图片 id 是按类别分组的编号不是按文件名排序的连续序号而且类别标签从 1 开始不是通常用的 0-199。同时文件名里包含类别名称比如 Black_footed_Albatross容易让人误以为可以直接从路径字符串解析标签实际上类别 id 必须查表确认路径字符串只是方便人类读。3.2 cub_util.py三张表按 id 合并我一般不会直接按行号 zip 这三个文件虽然原始文件行序一致但一旦有人手动删改过某一行后面的标签全部错位。更稳妥的做法是先把每个 txt 读成 dict以图片 id 为主键合并。import os def load_cub_metadata(data_root): def read_pairs(path, is_strFalse): pairs {} with open(path) as f: for line in f: parts line.strip().split() key int(parts[0]) val parts[1] if is_str else int(parts[1]) pairs[key] val return pairs images read_pairs(os.path.join(data_root, images.txt), is_strTrue) labels read_pairs(os.path.join(data_root, image_class_labels.txt)) splits read_pairs(os.path.join(data_root, train_test_split.txt)) meta {train: [], test: []} for img_id, rel_path in images.items(): if img_id not in labels or img_id not in splits: raise ValueError(fmissing metadata for image {img_id}) label labels[img_id] - 1 # 转成 0-199 is_train bool(splits[img_id]) meta[train if is_train else test].append({ id: img_id, path: os.path.join(data_root, images, rel_path), label: label }) return meta这段脚本的关键是 set 对齐后再合并。labels 和 splits 都以 dict 形式读入访问时不会因为行序变化出错。遇到缺失 id 直接抛异常而不是跳过避免后续 H5 文件里图像和标签数量不一致。label 减 1 的操作放在这里做后面训练代码就不用再处理标签偏移。3.3 create_h5_dataset.py把图片和标签写进 H5训练时直接从磁盘一张张读 11788 张小图IO 开销很大尤其是机械硬盘环境下每个 epoch 都要重新遍历一遍图片目录。把整个数据集封装成 H5 单文件后Dataloader 加载的是已经解码好的 numpy 数组训练速度会快很多。create_h5_dataset.py 做的就是这件事。import h5py import cv2 import numpy as np IMG_SIZE 224 MEAN np.array([0.485, 0.456, 0.406], dtypenp.float32) STD np.array([0.229, 0.224, 0.225], dtypenp.float32) def process_image(path): img cv2.imread(path) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # cv2 默认读成 BGR img cv2.resize(img, (IMG_SIZE, IMG_SIZE)) img img.astype(np.float32) / 255.0 img (img - MEAN) / STD return img with h5py.File(cub_200_2011.h5, w) as f: for split_name in [train, test]: samples meta[split_name] imgs np.stack([process_image(s[path]) for s in samples]) labels np.array([s[label] for s in samples], dtypenp.int64) f.create_dataset(f{split_name}/images, dataimgs) f.create_dataset(f{split_name}/labels, datalabels)这里把 BGR 转 RGB 放在数据制作阶段属于这条管线里最关键的坑之一。cv2.imread 读出来的是 BGR直接写进 H5 会导致所有图像颜色通道错乱模型在 RGB 预训练权重上完全无法收敛。IMAGE_SIZE 设为 224 是为了配合 VGG16 的输入要求归一化用的 mean 和 std 是 ImageNet 统计值不能随意改成 0.5。3.4 标签对齐最容易错的地方标签对齐最容易翻车的点不在解析脚本而在划分方式。CUB 官方提供了 train_test_split.txt训练和测试的分布已经固定直接按官方划分使用即可。如果自己重新按七三比例随机切报告里的精度无法和任何公开结果对比训练集和测试集之间可能还有鸟类图像重叠精度虚高。另一个容易被忽略的是H5 里的 labels 是 int64Dataloader 取出来直接交给 CrossEntropyLoss 没问题但如果中间有人手动把标签从 1 改回 0-199 后又做了一次减 1就会出现 -1 标签导致 loss 计算报错。我建议把标签偏移只在 cub_util.py 里做一次后续所有脚本都默认标签已经是 0-199减少重复操作的出错概率。4. 双线性CNN与迁移学习的落地训练两条路照着跑4.1 bcnn.py 训练主循环数据准备好了模型结构也有了接下来就是训练主循环。bcnn.py 里的做法和标准监督训练没有太大区别关键差异在于数据加载时要做一次维度转换H5 里存的图像是 N,H,W,C而 PyTorch 要求 N,C,H,W这一步漏掉会直接报 shape mismatch。import h5py import numpy as np import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset h5 h5py.File(cub_200_2011.h5, r) train_x torch.from_numpy(h5[train/images][:]).permute(0, 3, 1, 2) train_y torch.from_numpy(h5[train/labels][:]) train_loader DataLoader(TensorDataset(train_x, train_y), batch_size16, shuffleTrue, num_workers4) model BCNN(num_classes200).cuda() criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size20, gamma0.1) for epoch in range(40): model.train() for x, y in train_loader: x, y x.cuda(), y.cuda() out model(x) loss criterion(out, y) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() val_acc evaluate(model, val_loader) # 验证函数见 4.4 print(fepoch {epoch:02d} loss {loss.item():.4f} acc {val_acc:.4f})这里 batch_size 按 16 起步比较合理。BCNN 等于同时跑两个 VGG16 分支再加上 262144 维全连接显存占用明显高于普通 VGG16。我一般先在 8G 显存上试 batch_size16如果 OOM 就降到 8。weight_decay 设为 1e-4 是必要的262144 维全连接层参数量太大不加正则很容易在训练集上过拟合。学习率用 StepLR 每 20 个 epoch 降 10 倍前 20 个 epoch 完成粗拟合后 20 个 epoch 做精细调整。4.2 双线性模型的显存策略BCNN 的 peak memory 主要来自两个地方两个分支各自的前向激活以及 bmm 外积产生的 B×512×512 中间张量。batch_size16 时bmm 输出大约是 16×512×512×4 bytes约 16MB不算大真正的显存大头在 VGG16 的卷积激活。如果显存不够常见做法有两种第一是减小 batch_size第二是在分支输出后加一层 1×1 卷积把通道数从 512 降到 256全连接维度变成 256×25665536参数量直接缩到原来的四分之一。后者属于紧凑双线性池化Compact Bilinear Pooling的思路精度会略降但训练速度快很多。我在复现这套资源时更喜欢先加 1×1 降维跑通全流程确认数据处理没有问题之后再去掉降维层跑完整版 BCNN。因为完整版训练时间长如果数据管线有一个隐藏 bug跑到第 10 个 epoch 才发现精度不对时间成本太高。4.3 transfer.py迁移学习作为对照transfer.py 是这份资源的基线方案思路更直接加载 ImageNet 预训练 VGG16冻结特征提取层只训练分类层。import torch import torch.nn as nn import torchvision.models as models model models.vgg16(pretrainedTrue) for p in model.features.parameters(): p.requires_grad False model.classifier[6] nn.Linear(4096, 200) optimizer torch.optim.SGD(model.classifier.parameters(), lr1e-3, momentum0.9) # 第一阶段冻结卷积层只训分类器 for epoch in range(10): train_one_epoch(model, train_loader, optimizer) # 第二阶段解冻卷积层低学习率微调 for p in model.features.parameters(): p.requires_grad True optimizer torch.optim.SGD(model.parameters(), lr1e-4, momentum0.9) for epoch in range(20): train_one_epoch(model, train_loader, optimizer)迁移学习的两个阶段有明显区别。第一阶段用较大学习率训练分类器期望它快速适应 200 类鸟的标签空间第二阶段必须把学习率降到 1e-4否则微调幅度太大会破坏 ImageNet 上已经学好的底层特征。VGG16 的 classifier 是 Sequential 结构下标 6 正是最后一层 Linear(4096,1000)替换成 200 维输出即可。4.4 两条路线的对比与验证这套资源里 DIP Project - Final Report.pdf 把两条路线都做了系统对比Figure_CUB200.png 是训练曲线图。我复现时建议至少跑 40 个 epoch 的 BCNN 和 30 个 epoch 的迁移学习再对比因为 BCNN 收敛慢前 10 个 epoch 精度可能还不如 transfer容易让人误判方案无效。验证函数 evaluate 里常见做法是每次 epoch 结束跑一遍完整测试集计算 top-1 准确率如果想更细可以同时记录 top-5细粒度分类任务里 top-5 会明显高于 top-1能反映模型的鲁棒性。方案初始化学习率epoch 数显存占用精度趋势transfer.pyImageNet 预训练权重1e-3 / 1e-410 20低前期高上限一般bcnn.py双分支预训练权重1e-3 → 1e-440高前期低后期反超5. 常见问题排查五条实测踩坑记录5.1 精度一直卡在 10%通道顺序从没转对现象loss 正常下降但验证集 top-1 准确率始终在 10% 附近徘徊和随机猜差不了多少。原因cv2.imread 默认返回 BGR 顺序写进 H5 前没有调用 cvtColor 转成 RGB。模型加载的是 ImageNet RGB 预训练权重输入的是颜色通道错乱的图底层特征全部乱套。这个 bug 最隐蔽之处在于 loss 曲线看起来很健康模型照样能在训练集上过拟合只是测试集上完全不泛化。解决在 process_image 里强制加一行 cv2.cvtColor(img, cv2.COLOR_BGR2RGB)。从那以后我只要看到精度异常低第一反应就是检查通道顺序。5.2 训练报错 target 越界标签从 1 开始现象报错信息类似AssertionError: Expected target size [N, C], got [N]或者 loss 直接变成负无穷。原因CUB-200-2011 的类别标签是 1-200CrossEntropyLoss 期望的标签范围是 0-199。如果只解析并保存了原始标签没有减 1target 样本等于 200超出模型输出通道索引范围。单张图还好一旦某个 batch 里恰好包含类别 200 的样本训练进程直接崩溃。解决在 cub_util.py 读取 image_class_labels.txt 时就把标签减 1后续所有模型加载的标签默认 0-199。这个偏移只用做一次不要在训练代码里二次减 1。5.3 第一个 epoch 还没跑完 loss 变成 NaN外积数值不稳定现象BCNN 训练大概几十个 step 之后 loss 突然变成 NaN重跑一遍崩的位置略有差异但一定崩。原因外积结果在 sqrt 归一化之前数值范围很大如果某个位置的激活值本身就高外积后进入全连接层的输入可能达到上千的量级。学习率稍高一点梯度更新一步就过冲整个训练直接发散。双线性 pooling 对数值稳定性的要求比普通 CNN 高很多。解决sqrt 前加 1e-12 的 epsilonL2 归一化一定要保留学习率从默认的 1e-3 再降一半到 5e-4。我用这套参数跑下来非常稳。如果仍然 NaN检查 H5 里是否存在全黑或全白图片这类极端输入会拉爆外积的数值范围。5.4 一个 batch 里图片尺寸不一致H5 写入时忘了统一尺寸现象Dataloader 返回的 tensor 形状是单数stack 时报错Expected all tensors to be same size。原因CUB 原始图片尺寸不统一有的 600x800有的 300x400。create_h5_dataset.py 里如果只处理路径和标签漏了 resizenumpy 的 np.stack 会因为尺寸不一致直接抛异常。更隐蔽的版本是数据增强阶段用了 RandomCrop但 crop size 和 H5 里存的尺寸不匹配。解决在 process_image 中统一先 resize 到 224x224。如果在意宽高比可以先把短边缩放到 256再从中间裁剪 224x224。两种做法在细粒度分类里都有前者简单后者能保留更多原始信息但代码逻辑多一步。5.5 迁移学习效果不如随机初始化预处理差异现象transfer.py 跑出来的精度比从头训练还低而且 loss 下降缓慢像是模型一直在原地踏步。原因迁移学习成功的前提是输入分布与预训练一致。BCNN 的 create_h5_dataset.py 里用了 ImageNet 的 mean 和 std 做归一化但 transfer.py 如果漏了这一步直接把原始 0-255 像素喂给 VGG16预训练权重学到的特征统计量全被破坏。底层卷积冻结后分类器拿到的特征分布是错的自然学不动。解决两个模型共用一个 H5 数据集或者统一封装一个 Dataset 类把归一化逻辑收敛到一处。我当年就是两边各写各的预处理翻车翻了整整一个下午。现在习惯是把 mean、std 作为全局常量写在同一个配置区改的人想漏都难。6. 验证模型有没有真在学鸟混淆矩阵与注意力可视化6.1 跑一次混淆矩阵先看错在哪训练完成后除了看 top-1 准确率我强烈建议跑一次测试集混淆矩阵。细粒度分类的误差通常不是均匀分布的而是集中在某些外观相近的物种上。from sklearn.metrics import confusion_matrix import numpy as np y_true, y_pred run_all_predictions(model, test_loader) cm confusion_matrix(y_true, y_pred, labelsrange(200)) per_class_acc cm.diagonal() / cm.sum(axis1) worst_classes np.argsort(per_class_acc)[:10]看混淆矩阵时注意一个模式如果最差的类别集中在海鸥、燕鸥这类整体颜色相近的鸟说明模型主要靠全局颜色做判断如果错误集中在外形差异明显的类别上说明数据标签本身可能有问题。前者是细粒度分类的正常现象可以考虑在训练时加深颜色抖动增强来缓解。6.2 注意力可视化双线性分支在关注什么混淆矩阵只能告诉你错在哪注意力可视化能告诉你模型看到了什么。对 BCNN取最后一个卷积层的特征图按通道维度取平均上采样回原始分辨率再叠加在原图上。import torch.nn.functional as F def attention_map(feature_map): # feature_map: (B, C, H, W) att feature_map.mean(dim1, keepdimTrue) att F.interpolate(att, size(224, 224), modebilinear) return att.squeeze(1)正常的细粒度模型热力图会集中在鸟头、翅膀、尾羽这些局部判别区域。如果热力图铺满整个背景或者集中在鸟的躯干中间说明模型学到的是背景先验——比如某种鸟总出现在海边另一种总出现在树林这不是真正的物种分类换一张纯色背景的测试图精度马上崩。6.3 一个稳健性自检最后做个简单翻转测试把测试图片水平翻转重新预测看模型输出是否保持稳定。细粒度分类里水平翻转不改变鸟类物种理想的模型应该给出接近一致的预测结果。如果翻转后 top-1 从正确变成错误说明模型对方向敏感多半是训练时没有做随机翻转增强。这套资源里的数据处理脚本如果加上RandomHorizontalFlip效果会再上一个台阶。从那以后我每次拿到这种多阶段图像处理资源第一件事不是看网络结构而是打开数据处理脚本手动跑通一张图。网络结构写错了报错很显眼数据管线的错要拖很久才会在训练曲线上暴露这是最深的血泪经验。先单独读一张图、打印通道顺序和 tensor 维度、再跑一步前向核对外积维度这套自检流程能避开大半玄学希望帮到你。本文还有配套的精品资源点击获取