
简介面向数字图像处理课程大作业的图像细粒度分类项目以CUB-200-2011标准鸟类数据集为对象完整记录了从数据准备、特征学习到模型评估的工程流程适合计算机视觉与图像处理方向的学生作为课程设计或入门科研的参考蓝本。资源包共13个文件、4.76MB主体是4个Python脚本覆盖HDF5数据集创建、双线性卷积神经网络BCNN、迁移学习训练以及通用工具函数配套3份PDF报告、答辩PPT、Word说明文件和结果示意图其中最终报告与细分类讲解能辅助理解算法原理作业布置则明确了考核要求。目前已有974人学习目录结构按功能模块组织定位清晰便于按需查阅。通过该资源读者不仅能跑通细粒度分类的完整代码还能学习到双线性特征融合与迁移调参的实践经验并借鉴课程报告与答辩幻灯片的撰写思路对独立完成类似数字图像处理大作业有直接帮助。1. CUB-200-2011 细粒度分类大作业课程项目到底要求你做什么数字图像处理大作业撞上图像细粒度分类数据集用 CUB-200-2011这几乎成了很多高校课程里的标准三件套。别把细粒度分类当成普通分类任务——CUB 有 200 个鸟种、将近 1.2 万张图很多近缘物种的差异只是一圈眼纹、喙的弧度或者翼尖几根羽毛的颜色模型稍微偷懒就会在近缘类之间翻车。Peking University 这个后缀说明它是课程项目有截止时间有报告页数限制得在有限算力和有限调参次数下跑出一个能交代的准确率。下面按数据、模型、训练、避坑、提分这条线展开尽量给你能直接照抄的脚本和参数。2. CUB-200-2011 数据准备目录结构、标签文件与切分策略2.1 官方文件一次读明白images.txt 与 bounding_boxes.txt下载解压之后CUB-200-2011 就是一堆 txt 加一个 images 目录。这个数据集维护得很规范但规范不等于直观第一次接触的人会花不少时间在「这几个 txt 到底谁是标签」上。先记住一句话所有 txt 都是两列第一列永远是图片编号1 到 11788第二列才是各自的内容。文件第二列内容用途images.txt图片相对路径建立编号到文件的映射image_class_labels.txt类别编号 1~200建立图片到标签的映射train_test_split.txt1 为训练0 为测试官方划分按类分开bounding_boxes.txtx y w h 四个浮点数鸟在画面中的位置parts/ 目录15 个关键部位坐标做部件定位时用我一般直接用 pandas 读省去手写解析import pandas as pd data_dir /datasets/CUB_200_2011 images pd.read_csv(f{data_dir}/images.txt, sep , names[id, path]) labels pd.read_csv(f{data_dir}/image_class_labels.txt, sep , names[id, label]) split pd.read_csv(f{data_dir}/train_test_split.txt, sep , names[id, is_train]) boxes pd.read_csv(f{data_dir}/bounding_boxes.txt, sep , names[id, x, y, w, h]) df (images .merge(labels, onid) .merge(split, onid) .merge(boxes, onid)) df[label] df[label] - 1 # 标签从 1 开始CrossEntropyLoss 要从 0 开始 df[is_train] df[is_train].astype(bool) print(len(df), df[label].nunique())逻辑说明四个 DataFrame 通过图片编号 id 做内连接得到一张大表。label - 1这步很关键官方类别编号从 1 开始而 PyTorch 的 CrossEntropyLoss 要求标签从 0 开始漏掉这一步训练时第一个类的准确率永远是 0报错还往往看不出来。参数说明sep 只适合空格分隔的干净文本。CUB 的路径里没有空格类名用下划线连接所以安全。如果解压工具把文件路径改出了空格就改成sep\s用正则吞掉连续空白前提是第二列本身不能被继续切分。路径含空格时我建议直接重命名目录不要和解析较劲。另一个容易看走眼的地方bounding_boxes.txt 的坐标是浮点不是整数。有的同学直接 int() 取整在图片边缘的框会偏移几个像素对细粒度分类影响不大但做部件可视化时会明显看出来。我一般保留浮点等传给 PIL.Image.crop 再让它自己处理。2.2 训练/验证集切分按类切不按图切CUB 官方 train_test_split.txt 已经按类大致分开前 100 个类进入训练后 100 个类进入测试。这个划分的意图是「测试集里的物种模型从没见过」比同物种随机分难度高一个档次。但大作业调参不能直接用官方测试集否则等于拿考卷练手。常见做法是官方训练集里再按类切出 10%~15% 作为验证集最终报告时才碰官方测试集。from sklearn.model_selection import train_test_split df_train df[df[is_train]] df_val, df_final_train train_test_split( df_train, test_size0.15, # 15% 留作验证 random_state42, stratifydf_train[label] ) print(len(df_final_train), len(df_val))逻辑说明stratify 按 label 分层抽样保证验证集里 200 个类一个不少。CUB 每类平均约 60 张训练图验证集每类能分到 9 张左右足够算出可读的 per-class 准确率。参数说明test_size0.15 的意思是把官方训练数据里 15% 留作验证。如果训练时间紧可以压到 0.1如果每类图像特别少就用 K 折交叉验证而不是继续压验证集比例。random_state 固定 42是为了让复现实验时不因为随机切分打架。这里有个隐性要求按类切分后同一个类的图片不会同时出现在训练集和验证集。细粒度分类要评估的是「读图识种」不是「背图认鸟」。如果随机切模型可能靠记忆个体特征拿高分答辩时换一张图就露馅。2.3 全图还是 bbox数据预处理的第一个决定性选择CUB 图片大多是野外拍摄鸟经常只占画面的一部分。细粒度分类模型对物体位置非常敏感如果图片里有大片天空、树枝、水面模型很可能走捷径学背景而不是学鸟。数字图像处理课的作业里这一步属于目标定位预处理也是最容易拉开分差的地方。我的默认做法训练和验证都用官方 bbox 裁出鸟区域再缩放测试阶段再做全图分支辅助判断。bbox 裁切能去掉大量背景噪声把模型的 capacity 还给鸟身纹理、喙形、翼斑这些真实判别信息。import torch from PIL import Image from torch.utils.data import Dataset class CUBDataset(Dataset): def __init__(self, df, data_dir, transformNone, use_bboxTrue): self.df df.reset_index(dropTrue) self.data_dir data_dir self.transform transform self.use_bbox use_bbox def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] img Image.open(f{self.data_dir}/{row[path]}).convert(RGB) if self.use_bbox: x, y, w, h float(row[x]), float(row[y]), float(row[w]), float(row[h]) img img.crop((x, y, x w, y h)) if self.transform: img self.transform(img) return img, int(row[label])逻辑说明use_bbox 控制是否裁切。写成开关是为了方便后面消融实验——同一套代码把 use_bbox 从 True 改成 False就能对比「有定位先验」和「没有定位先验」的效果。参数说明Image.open(...).convert(RGB)这步不能省CUB 个别 JPEG 是灰度或带 alpha 通道不统一转 RGB训练到一半会报通道数不匹配。数据增强我习惯分两套import torchvision.transforms as T train_transform T.Compose([ T.RandomResizedCrop(224, scale(0.7, 1.0)), T.RandomHorizontalFlip(0.5), T.ColorJitter(0.2, 0.2, 0.2), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_transform T.Compose([ T.Resize(256), T.CenterCrop(224), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])参数说明RandomResizedCrop 的 scale(0.7, 1.0)。因为已经从全图裁到 bbox再随机裁掉 30% 区域是模拟遮挡和视角变化。ColorJitter 幅度不要大细粒度分类靠颜色区分物种抖太狠等于把标签搞乱。验证集不做随机增强只做固定缩放和中心裁剪这样才能让指标稳定可复现。3. 特征与模型选型为什么直接上 ResNet50 微调而不是手写特征3.1 传统特征在 CUB 上的瓶颈数字图像处理课的大纲通常会讲颜色直方图、纹理特征、边缘检测、SIFT、HOG。这些方法做通用分类没问题但细粒度分类要捕捉「同类里不同种」的差异传统手工特征有两个硬伤一是特征设计者不知道判别信息具体在哪二是特征没有针对任务做端到端学习。拿 SIFT 来说它对旋转和尺度不变性很好但近缘鸟类的局部纹理非常接近SIFT 描述子很难区分差异微妙的关键点。颜色直方图更直接光照一变直方图就整体偏移近缘种。经验值上在 CUB 全图上用颜色直方图加 SVM 只比随机高一点HOG 和 SIFT 类方法能到 30%~40%但想超过 50% 很困难。方法手工特征 vs 学习特征在细粒度任务上的实际表现颜色直方图 SVM手工受光照影响大近缘类分不开SIFT Fisher Vector手工能到中等水平描述子粒度不够HOG 线性 SVM手工对纹理敏感结构信息弱CNN 预训练微调学习特征自动聚焦判别部位精度明显更高这个对比不是劝你放弃数字图像处理课的内容。预处理、边缘、形态学这些手段在细粒度任务里仍然有用——裁 bbox 就是最典型的数字图像处理操作。只是模型主体要换成可学习的 CNN特征不再手动设计。3.2 用 PyTorch 加载预训练 ResNet50最小可跑通的代码选 ResNet50 而不是 AlexNet 或 VGG理由很实际VGG 参数量大在 1 万张图的小数据集上过拟合快AlexNet 结构太老ImageNet 预训练特征不够强。ResNet50 有残差结构训练稳定PyTorch 自带 ImageNet 预训练权重导入也就几行。import torch 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) model model.cuda()逻辑说明ResNet50 的 ImageNet 预训练权重已经学到通用的边缘、纹理、部件响应这就是细粒度分类的起点。替换最后一层 fc输出 200 类。model.fc.in_features取原全连接层输入维度ResNet50 是 2048写死 2048 也可以但用代码取更不容易在换 backbone 时踩坑。参数说明weights...会从网上下载预训练权重。机房机器不能联网时提前在能上网的机器上跑一次导入再把缓存文件拷贝到目标机器的对应目录这样训练时不依赖网络。显存或时间紧张时可以冻结 backbone 低层只训练后面几个 stagefor name, param in model.named_parameters(): if layer3 in name or layer4 in name or fc in name: param.requires_grad True else: param.requires_grad False逻辑说明requires_gradFalse的层不会在反向传播里更新。这个做法的收益是显存占用小、训练更快代价是 backbone 低层特征不会针对 CUB 微调。如果训练时间只剩两三天这个折中很划算时间够我倾向于全量微调。3.3 训练参数怎么设backbone 学习率与分类头学习率分开细粒度微调最忌讳所有层都用同一个学习率。ResNet 前几层学到的是通用视觉特征后几层和 fc 学到的是鸟种专属特征。统一用大学习率底层容易把预训练特征冲坏统一用小学习率fc 从头训练又太慢。我一般用 SGD 加两个参数组backbone_params [p for n, p in model.named_parameters() if fc not in n] fc_params model.fc.parameters() optimizer torch.optim.SGD( [ {params: backbone_params, lr: 1e-3}, {params: fc_params, lr: 1e-2}, ], momentum0.9, weight_decay5e-4 ) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max60)参数说明backbone 用 1e-3因为预训练权重已经接近好的解步子太大会把学到的通用特征打散fc 是新初始化的用 1e-2 让它快速收敛。weight_decay5e-4 是 ImageNet 微调常见的正则防止小数据集过拟合。CosineAnnealingLR 的 T_max 等于训练轮数60 个 epoch 后学习率平滑降到接近 0。细粒度分类的训练轮数有个经验范围30~80 epoch。CUB 数据量小模型很容易在 10 个 epoch 后就过拟合但细粒度判别特征需要更长训练才能从「大致对了」进化到「分得清近缘物种」。我的习惯是先跑 40 epoch 看 val loss 是否还在降再决定加到 80 还是提前停。别一上来就 100 epoch时间耗在没必要的调试上。4. 训练与评估闭环把模型跑起来并让报告有说服力4.1 完整训练脚本一次能跑完的骨架把前面的数据准备和模型组装起来写一个标准的 PyTorch 训练循环。大作业不需要分布式训练不追吞吐代码清晰、可复现、能讲清楚每一步是干什么的比炫技重要得多。from torch.utils.data import DataLoader from tqdm import tqdm import torch.nn as nn train_loader DataLoader(CUBDataset(df_final_train, data_dir, train_transform), batch_size32, shuffleTrue, num_workers4) val_loader DataLoader(CUBDataset(df_val, data_dir, val_transform), batch_size32, shuffleFalse, num_workers4) criterion nn.CrossEntropyLoss() best_acc 0.0 for epoch in range(1, 61): model.train() run_loss 0.0 for images, labels in tqdm(train_loader): images, labels images.cuda(), labels.cuda() optimizer.zero_grad() logits model(images) loss criterion(logits, labels) loss.backward() optimizer.step() run_loss loss.item() scheduler.step() model.eval() correct, total 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.cuda(), labels.cuda() preds model(images).argmax(dim1) correct (preds labels).sum().item() total labels.size(0) acc correct / total if acc best_acc: best_acc acc torch.save(model.state_dict(), best_model.pth) print(fepoch {epoch:02d} | loss {run_loss/len(train_loader):.4f} | val acc {acc:.4f})逻辑说明训练态和验证态要严格分开。model.train()会打开 BatchNorm 的统计更新和 Dropoutmodel.eval()会关上它们。忘了切状态是验证集准确率忽高忽低的常见原因。optimizer.step()之后再scheduler.step()这个顺序保证学习率按 epoch 衰减而不是按 step 衰减。参数说明batch_size32 是 224 分辨率下大多数单卡 GPU 的甜点。显存不够就降 16ResNet50 在 224x224、batch 32 下比较安全。保存 best_model.pth 用的判断标准是验证集准确率不保存在最后一个 epoch 的权重因为最后几步经常已经过拟合。4.2 评估指标准确率之外还要看 per-class 与混淆矩阵大作业报告如果只写一个总准确率答辩时很容易被问住。细粒度分类要交代的不是整体好坏而是「哪些类分得清、哪些类永远混」。CUB 200 个类里近缘物种组成的几个簇是难点比如绿鹃类和霸鹟类。只报一个 82% 说不清模型在哪里犯错。import numpy as np from sklearn.metrics import confusion_matrix all_preds, all_labels [], [] model.eval() with torch.no_grad(): for images, labels in val_loader: images images.cuda() preds model(images).argmax(dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) cm confusion_matrix(all_labels, all_preds) per_class_acc cm.diagonal() / cm.sum(axis1) worst np.argsort(per_class_acc)[:10] print(准确率最低的类索引:, worst) print(对应准确率:, per_class_acc[worst])逻辑说明confusion_matrix 的行是真实类别列是预测类别。cm.diagonal() / cm.sum(axis1)计算出每一类的查准率。打印准确率最低的 10 个类报告里可以拿它们做具体分析证明你确实知道模型哪里不行而不是碰运气跑出一个数。参数说明如果验证集里某些类样本太少per-class 准确率波动会很大看的时候要有心理准备不要拿一张图的正确与否下结论。报告里可以贴一张混淆矩阵的热力图只显示错误最多的 20 个类答辩时一眼就能看出问题集中在哪里。4.3 消融实验设计报告里的三组对照大作业报告要体现工作量最好用消融实验说话。我常用的三组对照设计全图 vs bbox 裁切同一模型、同一轮数、同一随机种子只看预处理方式对准确率的影响。这是数字图像处理课最关心的点因为 bbox 裁切就是图像几何变换。冻结 backbone vs 全量微调改动只需要在requires_grad设置里切换。冻结法训练快全量微调上限更高对照结果正好写进「模型调优」章节。TTA 多尺度测试把第 6 章的测试时增强作为推理阶段的消融证明你对测试环节也做了优化。每组实验保证三点数据集切分一致、优化器参数一致、随机种子一致。只改要比较的那一个变量。我见过不少报告把「换 backbone 换预处理 换优化器」三个变量一起改结果根本没法归因这种报告在答辩时最容易被追问到翻车。提示保存每一组实验的 log 文件不要只截图命令行输出。最后写报告的时候重新跑一遍不如翻 log 方便。5. 细粒度分类常见问题排查五条踩坑记录5.1 现象加载图片全部失败报 FileNotFoundError训练还没开始就卡住目录里明明有图代码却报找不到文件。 原因CUB 解压后顶层目录名是CUB_200_2011而 images.txt 里的路径相对的是解压后根目录。如果 data_dir 配成了.../CUB_200_2011/images再拼上row[path]就变成.../images/001.Red_eye_Vireo/xxx.jpg文件夹名对不上。 解决data_dir 指向解压后的根目录不要多拼一级读取前先把每一条 path 转成 str 类型再os.path.join。遇到奇怪字符先print(row[path])肉眼检查。5.2 现象验证集准确率很高一用官方测试集就下来一大截这是典型的评估方式不当不是模型不行。 原因调参期间拿官方测试集反复验证模型已经在测试集上做过隐式调参或者验证集没按类切模型在验证集里见过同类的其他个体记忆了个体特征。 解决把官方测试集锁起来直到报告前只用验证集做决策同时确认验证集按类切分和训练集类不相交。这是细粒度任务里最常见的数据泄露也是答辩时最容易失分的地方。5.3 现象loss 在几个 epoch 后卡住不动val accuracy 也不涨loss 曲线在高位走平怎么调都下不去。 原因学习率设置不当。常见两种情况一是 backbone 学习率设太大预训练特征被破坏模型陷入局部震荡二是 CosineAnnealing 的 T_max 跟实际 epoch 数不一致学习率已经提前降完后面全在空转。 解决把 backbone lr 降到 1e-4 再试fc lr 保持 1e-2确认 T_max 等于训练 epoch 数。如果还卡就把 weight_decay 从 5e-4 降到 1e-4。5.4 现象batch size 从 32 降到 8 之后准确率明显下降显存不够时很多人直接降 batch size结果准确率跟着掉。 原因ResNet50 的 BatchNorm 层对 batch size 很敏感batch 8 时每步看到的样本太少运行均值和方差波动加大训练不稳定。 解决显存不够优先降输入分辨率比如降到 160 或 128这比降 batch size 更可靠如果必须用 batch 8可以尝试冻结 BatchNorm 参数。另外用混合精度训练能省不少显存PyTorch 自带 GradScaler。5.5 现象训练正常但推理时很慢每张图要几十毫秒验证和测试阶段发现速度比预期慢了一个量级。 原因验证循环里忘了关梯度计算或者把训练数据增强直接用到了测试集每个 batch 都额外做一次 RandomResizedCrop又慢又不可复现。 解决推理时用with torch.no_grad():关掉梯度测试用独立的val_transform不带随机增强。DataLoader 的 num_workers 调到 4~8瓶颈往往在图像 decode 而不是前向推理。6. 用一个技巧把准确率再推一步多尺度测试与平均投票训练结束后的测试阶段有一招提高细粒度分类准确率的低成本方法多尺度加水平翻转的测试时增强业内习惯叫 TTA。做法是训练时固定 224 输入但推理时对同一张图生成 216、224、240 三个尺度的裁剪图再各自做一次水平翻转收集 6 张图分别过模型最后对预测结果取平均。def predict_prob_bag(model, pil_img, bbox, scales[216, 224, 240]): x, y, w, h bbox pil_img pil_img.crop((x, y, x w, y h)) model.eval() logits_bag [] with torch.no_grad(): for scale in scales: img pil_img.resize((scale, scale)) for flip in [False, True]: t img.transpose(Image.FLIP_LEFT_RIGHT) if flip else img tensor val_transform(t).unsqueeze(0).cuda() logits model(tensor) logits_bag.append(logits) probs torch.stack(logits_bag).mean(dim0) return probs.argmax(dim1).item(), probs逻辑说明多尺度相当于给模型输入多个分辨率的视图让它在更细的纹理信息和更稳的全局结构之间做平均。水平翻转对鸟类这种左右对称的物体天然友好。最后平均的是 logits 而不是概率实践经验上 logits 平均更稳定因为 softmax 会把某些类的概率压到接近 0平均概率会丢失置信度信息。 参数说明scales 不必太多216、224、240 就够再加推理时间翻倍提升有限。TTA 的代价是推理成本放大到原来的 6 倍需要 6 次前向而不是 1 次。跑完记得把 TTA 的预测结果存下来写报告时拿「有 TTA vs 无 TTA」做最后一组对比。我这个习惯是从多次细粒度任务里总结出来的先把数据、切分、训练闭环做扎实再去追技巧收益顺序反过来往往白忙一场。CUB-200-2011 的坑就这么多你按这套流程走至少不会在答辩前夜翻车。希望帮到你。本文还有配套的精品资源点击获取