简介这是一份数字图像处理课程大作业的完整参考实现围绕CUB-200-2011鸟类图像细粒度分类任务展开对应北京大学相关课程要求。项目用Python编写覆盖双线性卷积神经网络BCNN与迁移学习两条技术路线适合正在完成图像分类作业、需要参考工程代码和报告思路的高年级本科生与研究生。压缩包共13个文件总大小4.76MB其中4个py脚本构成代码主体用于数据集生成、BCNN训练、迁移学习调用和公共工具函数3个pdf包含最终报告、细分类讲解和大作业布置1个pptx用于答辩演示1个docx为解读文档2个txt提供说明与运行提示另外还附有模型文件和示例图片整体体积小、层次分明便于按需查看。目前已有974人学习下载。这套资料提供了从数据预处理、模型构建到结果可视化的完整工程链路能够帮助读者理解双线性特征提取与迁移学习在细粒度分类中的实际差异同时可借助报告、PPT和讲解文档快速梳理答辩与展示思路节省从头摸索的时间。1. 细粒度分类让通用模型翻车CUB-200-2011 大作业要解决什么问题同样是数字图像处理大作业别人交的是高斯滤波和 Canny 边缘检测你要交的却是在 200 种鸟里面把“白腹蓝姬鹟”和“白腹蓝鹟”分开——这就是图像细粒度分类。CUB-200-2011 是这个任务最常用的标准数据集11788 张图片、200 个物种平均每类只有不到 60 张样本。它的难点非常反直觉类间差异极小、类内差异极大同一只鸟换个姿态看起来像另一个物种而换一只同类鸟反而看不出区别。这门大作业适合两类人课程方向选了深度学习的同学以及想在有限算力内把整个视觉任务链路走一遍的从业者。它逼你把数据加载、模型选型、训练调参、错误分析全部亲手做一遍踩坑价值远高于跑通一个 MNIST。2. CUB-200-2011 不只是鸟图先读懂标签体系与数据加载细粒度分类和普通图像分类最大的差别不在模型而在数据标注的密度。CUB-200-2011 除了给你一张图和类别标号还额外提供了边界框、15 个部件关键点、312 个二值属性标注以及官方划分好的训练/测试名单。这些信息决定了你后面能做什么样的模型也埋着大作业最常见的坑。这一章先把数据摸透。2.1 五个核心文件与目录CUB 的标签不在文件名里CUB-200-2011 解压后的顶层目录是 images、attributes、parts以及一堆 txt 文件。图片虽然按类别放在images/001.Black_footed_Albatross/这样的子目录里但官方不允许你直接按子目录读标签因为类别编号、图片路径、边界框、部件关键点、划分标志分散在五个独立的 txt 里全部通过一个 1 到 11788 的图片 id 关联。需要关心的文件如下文件内容格式示例images.txt图片 id 与相对路径1 001.Black_footed_Albatross/Black_Footed_Albatross_0001_1.jpgimage_class_labels.txt图片 id 与类别标号1-2001 1bounding_boxes.txt图片 id 与边界框1 40 29 216 291train_test_split.txt图片 id 与划分标志1 11 训练0 测试parts/ 目录15 个部件关键点文件1 76 99图片 idxy最为关键的一点是图片 id、类别标号都是 1-based 索引不是从 0 开始。你如果直接拿int(label)喂进 CrossEntropyLossPyTorch 会静默接受并产生错位训练因为类别 200 会被越界截断或者映射到错一位的类别上。后面避坑章节会专门展开。另一个容易忽略的点是train_test_split.txt。官方划分方式是每个类别取一半图片做训练、另一半做测试而不是对整库随机划分。这个文件决定你最终评估结果的权威性自己 shuffle 就会导致同鸟不同照片同时出现在训练集和测试集里准确率虚高 5 到 8 个点。2.2 用 PyTorch 写一个不走样 DatasetBBox 与部件关键点一起读大作业里我一般不建议直接套网上现成的 CUB Dataset 代码因为很多版本只返回 image 和 label把 bbox 和关键点丢掉。后面的注意力引导、裁剪预处理、错误可视化都需要这些信息一开始不预留接口回头改起来非常痛苦。建议自己写一个 Dataset读全所有标注。import os from PIL import Image import torch from torch.utils.data import Dataset class CUB200(Dataset): def __init__(self, root, transformNone, split1, use_bboxTrue): self.root root self.transform transform self.use_bbox use_bbox # 图片 id - 相对路径 images {} for line in open(os.path.join(root, images.txt)): idx, rel_path line.strip().split() images[int(idx)] rel_path # 图片 id - 类别标号注意减 1 转为 0-based labels {} for line in open(os.path.join(root, image_class_labels.txt)): idx, label line.strip().split() labels[int(idx)] int(label) - 1 # 图片 id - 边界框 [x, y, w, h] bboxes {} if use_bbox: for line in open(os.path.join(root, bounding_boxes.txt)): idx, x, y, w, h line.strip().split() bboxes[int(idx)] [float(x), float(y), float(w), float(h)] # 图片 id - {部件索引: [x, y]} parts {} part_dir os.path.join(root, parts) part_names [p for p in sorted(os.listdir(part_dir)) if p.endswith(.txt)] for pid, name in enumerate(part_names): for line in open(os.path.join(part_dir, name)): idx, x, y line.strip().split() idx int(idx) parts.setdefault(idx, {})[pid] [float(x), float(y)] # 根据官方划分取训练或测试子集 split_ids [] for line in open(os.path.join(root, train_test_split.txt)): idx, is_train line.strip().split() if int(is_train) split: split_ids.append(int(idx)) split_ids.sort() self.samples [] for idx in split_ids: self.samples.append({ img_path: os.path.join(root, images, images[idx]), label: labels[idx], bbox: bboxes.get(idx), parts: parts.get(idx, {}), }) def __len__(self): return len(self.samples) def __getitem__(self, i): s self.samples[i] img Image.open(s[img_path]).convert(RGB) if self.transform: img self.transform(img) return img, s[label], s[bbox], s[parts]这段代码把五个 txt 一次性读进内存实际运行时耗时可以忽略。注意split_ids.sort()这一行保证每个 epoch 的数据顺序完全一致复现实验时省很多事。part_names过滤了所有.txt后缀文件避免parts目录里如果混入说明文档导致部件索引错位。调用这个 Dataset 时split1读训练集split0读测试集。部件关键点在大部分分类训练里用不上但后面做热图注意力引导或者答辩可视化时你已经有了现成的接口。BBox 同理。如果显存紧张可以设置use_bboxFalse跳过bounding_boxes.txt读取。2.3 损坏图片与标注缺失预处理脚本先跑一遍CUB-200-2011 这个数据集本身整理得算干净但架不住下载解压过程中偶发文件损坏尤其在某些云盘转存场景下会出现 0 字节图片。跑训练到一半才发现某张图打不开会直接中断整个 epoch。我习惯在训练前先写一个 30 秒的检查脚本把坏图和非法标注全部揪出来。from PIL import Image import os bad_images [] for s in dataset.samples: try: im Image.open(s[img_path]) im.load() # 真正解码像素verify() 只查文件头 except Exception: bad_images.append(s[img_path]) print(bad images:, len(bad_images)) for p in bad_images[:10]: print(p)注意这里用的是im.load()而不是verify()。verify()只检查文件头是否完整遇到截断的 JPEG 可能依然通过但后续torchvision.transforms解码时会抛错。load()会把像素真正读进内存坏文件当场暴露。还需要检查 bbox 是否越界、部件关键点是否落在图片外。CUB 的部件文件里未标注的关键点会用-1 -1占位不能当成真实坐标。检查时要注意x、y 任意一个为负数就直接跳过该点不要换算成张量后让 NaN 混进 Loss。3. 为什么通用分类模型不够细粒度任务的选型逻辑在这个大作业里最忌讳的是一上来就抱着torchvision.models.resnet50训练不加任何改造。通用分类模型在 ImageNet 上表现好是因为 ImageNet 的 1000 类之间差异足够大——猫和狗、卡车和公交车靠全局形状和颜色就能分开。但 CUB-200-2011 不是这个量级的问题。3.1 细粒度分类的本质类间方差小、类内方差大细粒度分类要区分的是同一基类下的不同子类比如 200 种鸟。很多鸟在整体轮廓、颜色分布上高度相似真正的判别信息集中在局部喙的形状、翅膀上的斑纹、眼周的颜色、尾羽的长度。一个全连接层直接接在 ResNet 的全局平均池化后面学到的特征图会被整张图片的“平均外观”支配局部判别区域的信息在池化过程中被稀释掉。另外CUB 每类训练图片只有约 30 张单靠有限样本去学习细微差异模型很容易过拟合到背景、拍摄角度、光照这些无关因素上。这也是为什么在这类大作业里传统数字图像处理的手工特征方法基本行不通——SIFT、HOG 能描述纹理梯度但没法抽象出“这种鸟的翅膀覆羽有白色边缘”这种语义级别的判别模式。3.2 三条技术路线从 Bilinear CNN 到注意力引导细粒度分类在学术上已经沉淀了几类成熟做法选型时主要考虑大作业的时间跨度和算力上限。第一类是基于二阶特征交互的方法代表作是 Bilinear CNN。它用两个网络分支提取特征在外积空间里计算特征通道之间的相关性能捕捉到部件之间的共现关系比如“红色头顶”和“黑色喙”同时出现。问题是外积后特征维度爆炸512 通道外积后得到 26 万维全连接层参数量惊人训练时显存和收敛速度都很难受。第二类是注意力机制代表是 NTS-Net、WS-DAN。这类模型会自己发现“应该看哪里”通过对抗采样或多阶段注意力发现网络寻找判别性局部区域。效果确实好但实现复杂度高训练过程不稳定大作业周期内容易在 bug 上消耗大量时间。第三类是检测加分类的两阶段方案。先用 Groud Truth BBox 把鸟裁剪出来裁掉背景干扰然后直接做分类。这是细粒度分类里最朴素但最有效的手段之一。CUB 的 BBox 已经给了不利用就浪费了标注信息。3.3 选型结论以 ResNet50 为骨干加侧分支性价比最高对大作业而言我推荐的 baseline 是ImageNet 预训练 ResNet50 去掉原始 FC 层 全局特征与局部特征拼接 用 BBox 裁剪作为预处理。这个方案实现量小效果稳定常见做法是微调后 Top-1 能到 70% 到 78% 之间足够支撑一份优秀的大作业报告。预训练在这里不是可选项而是必选项。CUB 单类训练样本太少从零训练一个深层的卷积网络只能学到颜色和纹理基元学不到结构性的部件语义。预训练模型已经在 ImageNet 上见过大量自然图像具备了边缘、角点、纹理的完整字典在 CUB 上微调的过程本质上是把这些基元重新组合成鸟类特有的判别模式。这个前提是吃显存的但 ResNet50 在单卡 11GB 上完全可以跑不需要多卡分布式的额外复杂度。另外我在做选型时会把 Bilinear CNN 放在“如果 baseline 跑通还想冲高分”的候选池里而不是一开始就上。注意力机制的模型则适合组队大作业里有人专门负责模型实现的情况。独立完成的大作业把 BBox 裁剪、预训练微调、数据增强做扎实性价比远高于追一篇论文的完整复现。4. 训练全套参数数据增强、超参与评估选型定下来之后训练环节决定你最终能拿多少分。CUB-200-2011 的数据量不大训练集约 5900 张40 到 60 个 epoch 足够收敛。这一章直接给出参数和可复现的配置。4.1 数据增强策略随机裁剪、颜色抖动与 CutMix细粒度分类对输入分辨率比普通分类更敏感。224x224 在 ImageNet 上够用但在 CUB 上会丢失喙部、眼部等微小细节。我一般把训练输入分辨率设成 384 或 448。这样可以保留更多判别性局部纹理代价是显存占用上升。如果你只有 8GB 显存建议用 384batch size 设 16配合梯度累积。训练增强按以下组合from torchvision import transforms import torch train_transform transforms.Compose([ transforms.Resize((480, 480)), # 先放大到较大尺寸 transforms.RandomCrop(448, padding16), # 随机裁剪等效目标尺度扰动 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((448, 448)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])这里有两个细节。第一RandomCrop(448, padding16)相当于在原图上先做固定 448 裁剪但每次裁剪位置随机偏移等效于轻微的平移增强比单纯的 CenterCrop 效果好得多。第二训练用随机裁剪而测试用直接 Resize会造成训练和测试时目标尺度分布不一致。我在实践中发现这个偏差会掉 1 到 2 个点更稳的做法是测试时也采用中心裁剪流程先 Resize 到 512再 CenterCrop 到 448。ColorJitter的幅度要克制。鸟类数据集里颜色本身就是类别的重要线索调太狠会把“红色头顶”这种判别性特征破坏掉。0.2 的幅度是安全区间不要用 0.5。4.2 训练超参建议LR、Batch Size、Epoch 与 Cosine AnnealingCUB 微调最忌讳的是学习率一步到位。预训练模型的 BN 统计已经适配 ImageNet 数据分布过大的初始学习率会瞬间破坏底层特征。我习惯把初始学习率设为 0.01但用一个 epoch 的 warmup 从 0.001 线性升上来。SGD 优化器在细粒度任务上比 Adam 稳定尤其配合长 schedule最终精度通常更高。import torch.nn as nn import torchvision.models as models from torch.optim import lr_scheduler model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V1) model.fc nn.Linear(model.fc.in_features, 200) optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay1e-4) scheduler lr_scheduler.CosineAnnealingLR(optimizer, T_max60, eta_min1e-5)T_max60表示余弦退火周期覆盖整个训练过程学习率从 0.01 光滑下降到 1e-5。配合 warmup完整的 schedule 是第 1 个 epoch 从 1e-3 线性升到 1e-2之后 59 个 epochs 按余弦曲线衰减。这个配置比 fixed LR 或者 StepLR 在细粒度任务上稳定高出 1 到 2 个点。batch size 的选择直接受输入分辨率影响。448 分辨率下 ResNet50 的中间特征图是 14x14单卡 11GB 显存建议 batch size 16通过梯度累积达到等效 64 的效果。如果 batch size 小于 16BN 统计会不太稳定建议冻结 BN 层或者使用 SyncBN。Epoch 数量 60 是经验值。CUB 数据量小40 epoch 后验证准确率基本进入平台期继续训练容易过拟合。用 CosineAnnealing 的好处是即使多跑了十几个 epoch学习率已经接近 0对模型的破坏力很小。4.3 评估与可视化Top-1 不是唯一指标大作业评估指标不能只看 Top-1。CUB-200-2011 有 200 个类别整体 Top-1 会把稀有类别的表现掩盖掉。我一般同时算三个指标Top-1 准确率、Top-5 准确率、每个类别准确率的算术平均。最后这个指标对大作业很重要因为它把每个类视为等权如果你某几个类别完全没学会它会明显拉低分数比 Top-1 更能暴露问题。错误可视化是答辩时的加分项。把预测错误的样本拼成九宫格每张图标注“真实类别 / 预测类别 / 置信度”。你会发现错误往往集中在形态相近的类对上比如“黑脚信天翁”和“黑背信天翁”。这时去查classes.txt看两个类是否属于同一个属就可以在报告里写出有价值的分析——模型混淆的不是随机噪声而是生物学上确实接近的物种。混淆矩阵也建议画出来。200x200 的完整混淆矩阵太密可以只筛选出错误样本数超过 5 的类别对做局部混淆矩阵展示。这是我见过的性价比最高的报告图表之一直接体现你对数据分布的理解而不是只会跑模型。5. 大作业避坑实录5 个让我返工重跑的细节这一章写的每个坑都是真实发生过的有些隐藏得很深不打印中间结果根本发现不了。每条按现象、原因、解决三个层面展开希望你看完能少走我走过的弯路。5.1 坑 1训练集和测试集没按官方划分准确率虚高 8 个点我的一个同学第一次跑 CUB 时直接sklearn.model_selection.train_test_split把所有图片按 0.5 随机划分。结果 Top-1 达到了 82%比官方划分下所有公开 baseline 都高他一度以为自己做出了 SOTA。后来检查才发现CUB 数据集的图片是鸟类个体在不同姿态下的多张照片随机划分会把同一只鸟的照片同时分到训练集和测试集。模型本质上在“认鸟”而不是“认物种”测试时看到熟悉的个体就输出正确标签泛化性能完全虚高。原因就是train_test_split.txt的划分逻辑是按类别等分而不是按图片随机。解决办法是严格按官方文件过滤数据集每个类别 30 张训练、30 张测试。这个坑的教训是任何标准数据集的划分文件都是实验的宪法不要自己造轮子替代。5.2 坑 2BBox 裁剪后的尺寸不统一目标尺度一直在抖使用 BBox 做预处理时我从bounding_boxes.txt读到的坐标直接裁剪然后统一 Resize 到 448。看似没问题但某些图片的 BBox 只框住了鸟身主体翅膀展开或者尾羽伸出框外直接裁剪会截断判别性部位。我试过把 BBox 按 1.2 倍放大后再裁剪训练效果有明显提升。具体操作是x - 0.1 * wy - 0.1 * h宽高各乘 1.2然后 clamp 到图片范围内。这个 padding 比例不要随意改1.15 到 1.25 之间是常见取值区间过大失去了去除背景的意义过小则截断部件。我在实验中 1.2 倍表现最稳。另外训练时对这个裁剪区域做随机偏移扰动会比直接 CenterCrop 效果更好。5.3 坑 3类别标签是 1-based 索引错位一个单位静默训练CUB 的类别标号范围是 1 到 200而nn.CrossEntropyLoss要求标签范围是 0 到 199。如果你在 Dataset 里不做减一操作模型训练时类别 200 会触发 PyTorch 的索引越界报错但类别 1 到 199 会静默地映射到错误的类别上损失值看起来正常下降准确率却一直在低位徘徊。这个坑的症状非常隐蔽训练 Loss 从 4.5 降到 2.0但验证 Top-1 只有 20% 左右和随机猜差不多。排查方式是抽查一个 batch 的标签最大值如果等于 200 而不是 199问题就锁定了。解决方法是int(label) - 1并且在 Dataset__getitem__里打印前几条记录确认。5.4 坑 4水平翻转后部件关键点坐标没有映射可视化全错位加了RandomHorizontalFlip增强后图片左右翻转但部件关键点的 x 坐标不会自动跟着翻转。如果你用关键点做注意力监督或者只是画可视化对比图翻转后的关键点会落在鸟体外侧看起来像标注错乱。这个问题在纯分类任务里不影响 Loss但一旦混合了关键点 Loss模型会学到错误的空间关系。解决方式是翻转后把所有关键点的 x 坐标映射为新坐标x_new W - 1 - x_old其中 W 是翻转后的图片宽度。如果 Dataset 里做了翻转那就需要在 transform 之外手动同步处理 bbox 和关键点。我的习惯是给 Dataset 加一个do_flip参数在__getitem__里统一调用一个flip_annotation函数保持数据与标注的一致性。5.5 坑 5Normalize 的 mean/std 用错Loss 振荡不收敛有一次我用transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010))训练 CUB这是 CIFAR-10 的标准化参数不是我预想中的 ImageNet 参数。当时训练 Loss 忽高忽低验证准确率在 40% 到 60% 之间震荡白白跑了 20 个 epoch。原因是预训练模型的特征分布是按 ImageNet 的 RGB 均值(0.485, 0.456, 0.406)和方差(0.229, 0.224, 0.225)标准化的输入数据如果不按同一分布对齐预训练权重一开始就面对分布偏移的输入底层 BN 统计被扰动。排查方法很简单打印一个 batch 的输入 tensor 均值确认是否接近零均值单位方差。这个坑在换数据集时特别容易复发最好把 Normalize 参数统一写成一个常量配置供所有脚本复用。6. 提分三板斧从 72% 到 85% 的实用技巧如果你的 baseline 已经稳定到 75% 左右三个技巧能继续往上提而且每一条的实现成本都不高。第一板斧让 BBox 参与训练但不用复杂检测头。最直接的做法是把 BBox 中心的归一化坐标作为辅助监督在 ResNet 倒数第二层特征上接一个 1x1 卷积回归热图与分类 Loss 相加组成多任务 Loss。这只加了一个小分支显存开销几乎为零却能强制模型把特征响应集中在鸟体中心附近减少背景干扰。我实际测试下来比纯 BBox 裁剪还能再涨 1 到 2 个点。第二板斧测试时增强。推理阶段不要只跑一次 448 的 CenterCrop把输入 Resize 到多个尺度——256、384、512——各做一次预测再叠加水平翻转的预测最后对概率取平均。这个方法通常能让 Top-1 稳定提升 1 到 1.5 个点且完全不改训练流程。第三板斧分类别错误分析。把测试集所有错误样本按“真实类别-预测类别”分组找出错误数量最多的前 10 对逐一观察图片。你大概率会发现错误集中在特定羽毛颜色模式上这时回看 Normalize 参数是否正确、颜色增强是否过强往往能找到模型系统的偏差来源。我当年在这门大作业上最有价值的一步不是换更强的骨干网络而是靠错误可视化定位出模型对“白色腹部”这一特征的过度依赖调整增强策略后直接涨了 3 个点。这三板斧用好后CUB-200-2011 上做到 85% 左右的可信结果并不困难。最后说一句个人教训我做这个项目时最亏的一步是没在一开始就把 BBox 的 padding、翻转映射、1-based 标签这三个问题同时统一处理结果每个坑都让我重跑一轮完整的训练。后来我把数据预处理全部收敛成一个函数任何改动都先打印十张增强后的图和对应标注确认这个习惯让我后面的实验再没因为数据问题返工。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?