简介:这是一份围绕《计算机视觉与深度学习实战——以MATLAB和Python为工具》第30章“基于深度学习的视觉场景识别”展开的项目开发案例教程PDF,定位为面向希望快速上手深度学习图像识别任务的初学者与研究者的技术资料。内容以Corel图像库为实验数据,系统讲解matconvnet工具箱安装配置、CUDA/cudnn与GPU环境搭建、训练集制作、网络模型设计、训练及识别验证全流程,并介绍AlexNet、VGGNet等预训练模型的实际应用,适合视觉场景分类与人脸识别相关课题的算法复现、课程实验和毕业设计参考。压缩包为单个PDF文件,大小仅1.25MB,便于下载与离线阅读。资源已有1349人学习,案例步骤完整、组织紧凑,可帮助读者降低环境配置门槛,理解深度学习自动提取特征相比传统机器学习的优势,并快速将方法迁移至自己的视觉识别项目中。
1. 这是一条大多数计算机视觉大作业都愿意绕开、却比目标检测更易出成果的赛道
视觉场景识别要做的很简单:给一张照片,判断它属于“厨房”“街道”“森林”“航站楼”这类场景类别。很多把《计算机视觉与深度学习实战》当参考书的人,一上来就扑向目标检测和语义分割,反而把场景识别当成“图像分类加个标签”,这个误判会直接导致后续选错模型、选错数据组织方式。事实上,场景识别不是认出一个物体就完事,它要求模型从整张图的布局与上下文里归纳出类别,这种“靠全局证据做判断”的任务,恰恰最容易用现成的预训练卷积网络做出稳定结果,也最适合作为从理论课跨到项目开发的第一个完整闭环。以 MATLAB 和 Python 两条工具链同时推进的教程定位,也正对应着两类人群:算法原型喜欢 Python,课程设计和毕设交付却常被要求用 MATLAB。这篇笔记就按“选型 → 数据 → Python 实现 → MATLAB 实现 → 踩坑 → 验证”的顺序,把这条赛道讲透。
2. 场景识别不是图像分类加个标签:模型选型与数据准备
2.1 场景识别与分类、检测的边界在哪,为什么别人老跑偏
先厘清一个经常被问的问题:计算机视觉和机器学习到底什么关系。机器学习提供了分类器、聚类、降维这些通用工具,计算机视觉则把任务限定在图像和视频上。场景识别在形式上就是图像分类,但它比“猫狗分类”难得多:同一个“厨房”,可以是燃气灶配不锈钢橱柜的现代风,也可以是土灶加木碗的乡村风;同一张“街道”,晴天和雨夜的外表差异甚至超过“街道”和“小巷”之间的差异。目标检测面对这类问题会直接崩溃,因为检测需要先给物体画框,而场景里“该框谁”本身就是个问题——框一辆车、一棵树还是整个人群?都不足以证明这是高速公路。
从工程角度,场景识别应该按“全局表征 + 语义归纳”来做。早期做法是用 HOG、GIST 这类手工特征描述整幅图的纹理与空间结构,再交给 SVM。这套方案在光照稳定的小数据集上还能用,一到遮挡、视角变化剧烈的真实照片就翻车,因为手工特征抓不住“物体之间的组合关系”。深度学习起来之后,卷积网络天然把底层纹理逐步组织成部件、物体、场景语义的层级结构,预训练模型最后几个卷积通道里往往已经编码了“沙发附近常出现茶几”“车流旁边常有车道线”这类空间共现信息,这让场景识别成了迁移学习最受益的任务之一。所以结论很直接:别把场景识别当简单分类来做,也别拿目标检测框架硬套,直接使用预训练卷积网络做迁移学习才是投入产出比最高的路线。
2.2 迁移学习方案:选哪个模型、冻结还是微调
模型选型上,我一般优先推荐 ResNet-18,而不是更深更准的 ResNet-50 或 VGG-16。原因很现实:场景识别数据量通常不大,几十到几百张每类,ResNet-50 在这种规模下反而更容易过拟合;VGG-16 计算量太大,CPU 训练一个 epoch 就要等很久,调试节奏被拖垮。ResNet-18 结构足够表达场景级语义,又轻到可以在没有 GPU 的机器上跑完整个项目,这在实际开发里非常重要——很多课程设计和大作业就是在普通笔记本上完成的。即便有 GPU,ResNet-18 的快速迭代也能让你把时间花在数据和调参上,而不是干等训练。
确定模型后,第二个问题是冻结还是微调。“冻结卷积层、只训练最后分类头”是最稳妥的起步方案:预训练权重已经在 ImageNet 上学到了通用的纹理和部件特征,场景识别需要复用的正是这些底层能力,只需要在顶层学习“这些特征如何组合成一个场景”。当每类样本不足 500 张时,全量微调的风险很高,过低的学习率会让底部卷积层学不到东西,过高的学习率又会让预训练权重被冲毁。更常见的做法是折中:冻结前面大部分层,只解冻最后一个残差块和分类头,用一个很小的学习率更新。
如果你用的完全是公开场景数据集(比如 SUN397 或 Places365 的某个子集),那预训练 Source 本身就是 ImageNet,复用度没问题。但如果是室内监控、车载摄像头这类分布差异很大的数据,建议第一步先用冻结方案跑通,再尝试解冻部分层。这个“先封闭后解冻”的顺序能帮你快速判断问题是出在特征不适配还是分类头没学好,避免一上来就陷入全量微调的黑匣子。
2.3 数据怎么搭:目录结构、类别数量、哪些公开集能复用
数据组织上,无论后面用 Python 还是 MATLAB,我都强烈建议按“目录名即标签”的方式来放。PyTorch 的ImageFolder和 MATLAB 的imageDatastore都原生支持这个结构,训练代码可以完全绕开手动读标签的繁琐工作。一个典型的小型项目如下:
data/ ├── train/ │ ├── beach/ (~150张) │ ├── forest/ (~150张) │ ├── highway/ (~150张) │ ├── kitchen/ (~150张) │ └── livingroom/ (~150张) └── val/ ├── beach/ (~30张) ├── forest/ (~30张) ├── highway/ (~30张) ├── kitchen/ (~30张) └── livingroom/ (~30张)类别数量没有硬性标准,我只说经验值:每类训练样本 100 张是底线,150 到 300 张能出比较稳的结果;低于 50 张时,无论模型多好都容易过拟合。数据来源常见的有三种:公开数据集里抽子集、自己用手机拍、从长视频里抽帧。视频抽帧时要去掉连续相似帧,否则验证集和训练集会混入几乎相同的画面,准确率虚高,落地时立刻现原形。
文件名也值得讲究。我见过太多项目因为文件名里有中文、空格或特殊符号,在 MATLAB 里读图报错或者在 Python 里路径解析出错。规范可以定成这样:类别_编号_场景条件.jpg,比如kitchen_013_night.jpg、highway_007_rain.jpg。这种命名方式还有个额外好处:以后做错误分析时,从文件名就能直接看出模型是不是在某个特定光照下更容易出错,这比打开图片盲猜高效得多。
2.4 MATLAB 还是 Python:同一个项目两条工具链的分工
标题里把 MATLAB 和 Python 并列,不是制造对立,而是对应两种真实处境。Python 生态在“算法探索期”有明显优势:PyTorch 的调试信息直观、社区代码量大、可视化工具多,遇到问题随手能搜到解决方案。MATLAB 则强在“工程交付期”:Deep Learning Toolbox 把数据流、训练、评估封装得很规整,代码量少,图表呈现专业,而且不需要处理 Python 环境里依赖冲突的问题。很多学校的课程设计和毕业论文明确要求用 MATLAB,这也是标题并列两种工具的初衷。
| 对比项 | Python (PyTorch) | MATLAB (Deep Learning Toolbox) |
|---|---|---|
| 模型加载与修改 | 灵活,但需手动管理权重状态 | resnet18直接取网络,图层可视化清晰 |
| 数据流处理 | 自己写 Dataset 和 transform | imageDatastore+augmentedImageDatastore一步到位 |
| 训练过程监控 | 需自己打印或接 tensorboard | trainingOptions自带训练进度图 |
| 环境问题 | 安装、版本、CUDA 兼容性偶发头疼 | 官方工具箱一体,但下载模型依赖网络 |
| 互通桥梁 | 可导出 ONNX 给 MATLAB | 可导入 Python 导出的 ONNX |
我的习惯是:快速验证想法在 Python 里做,一旦方案定型,再用 MATLAB 复现同一套流程用于交付。接下来的两章分别给出两条路线的最小可运行方案,并在 4.3 节用 ONNX 把两边接通,这样你就不必把时间花在“到底选哪个工具”的纠结上。
3. 先把 Python 路线跑通:PyTorch 迁移学习最小实现
3.1 环境和目录:这台机器要装什么、怎么组织训练集
Python 环境配置这件事,很多初学者卡在最前面。常见问题是先纠结 vscode python 环境配置还是装 PyCharm,其实两者都能跑,区别只是编辑器偏好。核心依赖是 PyTorch、torchvision 和 matplotlib,安装命令按官方推荐的对应 CUDA 版本即可;没有 GPU 就装 CPU 版,这篇方案里 ResNet-18 加一两百张图完全可以在 CPU 上完成训练,只是每个 epoch 要多等一会。我自己的习惯是先用python -c "import torch; print(torch.__version__)"验证安装成功,再继续下一步。
数据组织沿用上一章的目录结构,用torchvision.datasets.ImageFolder可以直接按子目录名生成标签。需要注意文件名只在调试时起参考作用,ImageFolder最终按目录顺序映射类别索引,所以训练前最好打印一遍dataset.class_to_idx,确认beach对应 0、forest对应 1,否则后面看混淆矩阵时会看不懂横纵轴含义。
3.2 训练脚本:冻结卷积层、只学分类头
下面是这份最小实现的核心代码。为了控制篇幅,我把它拆成“准备与模型”和“训练循环”两段,实际用时合到一个文件里即可。
import torch import torch.nn as nn import torchvision from torchvision import datasets, transforms, models from torch.utils.data import DataLoader # 预训练权重:torchvision 0.13+ 推荐 weights=,老版本用 pretrained=True model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) num_classes = 5 # 冻结所有卷积层,只训练最后的全连接分类头 for param in model.parameters(): param.requires_grad = False model.fc = nn.Linear(model.fc.in_features, num_classes) # 新加的分类头默认 requires_grad=True,无需额外设置 train_tf = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.5, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.2, 0.2, 0.2), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) val_tf = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) train_ds = datasets.ImageFolder('data/train', train_tf) val_ds = datasets.ImageFolder('data/val', val_tf) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=2) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=2) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.1)# 训练循环 for epoch in range(10): model.train() running_loss, correct, total = 0.0, 0, 0 for images, labels in train_loader: outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() running_loss += loss.item() _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) scheduler.step() print(f"epoch {epoch+1}: train loss={running_loss/len(train_loader):.4f} " f"acc={correct/total:.4f}") # 验证集评估 model.eval() correct, total = 0, 0 with torch.no_grad(): for images, labels in val_loader: outputs = model(images) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) print(f"val acc: {correct/total:.4f}")这段代码里有几个参数值得展开说明。RandomResizedCrop(224, scale=(0.5, 1.0))会让模型看到不同比例的同一场景,这比固定中心裁剪更能抵抗尺度变化;ColorJitter(0.2, 0.2, 0.2)调节亮度、对比度、饱和度,模拟真实环境下光照差异。输入图像必须归一化到 ImageNet 的均值和标准差,否则预训练权重会失效。学习率设为1e-3对一层分类头是合适的,但如果后续解冻卷积层,这个学习率对预训练部分就偏大,需要拆成两组参数分别设置。weight_decay=1e-4起到轻量正则作用,对样本少的情况有益。
3.3 看验证曲线与混淆矩阵,判断模型是不是在“背题”
训练完成后,只打印一个验证准确率远远不够,尤其当准确率在 70% 左右徘徊时,你根本不知道模型错在哪里。我建议至少输出一张混淆矩阵,它一眼就能暴露“厨房”和“客厅”是否被混淆、“街道”是否被误判成“高速公路”这类问题。
import numpy as np from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay import matplotlib.pyplot as plt all_preds, all_labels = [], [] model.eval() with torch.no_grad(): for images, labels in val_loader: outputs = model(images) _, preds = torch.max(outputs, 1) all_preds.extend(preds.numpy()) all_labels.extend(labels.numpy()) classes = list(train_ds.class_to_idx.keys()) cm = confusion_matrix(all_labels, all_preds) ConfusionMatrixDisplay(cm, display_labels=classes).plot(cmap='Blues') plt.xticks(rotation=45) plt.tight_layout() plt.show()看混淆矩阵有一个实用技巧:先找对角线上的数字,再找矩阵中最大的非对角线数字。如果某个非对角线数字特别大,比如kitchen有 30% 被归到livingroom,那说明模型学到了“沙发、电视、木地板”这类共用线索,而没有学到“灶台、水槽、油烟机”这类判别线索。这时第一反应不是盲目加数据,而是去验证集里抽几组被误判的图片,看看它们是不是真的在视觉上很难区分。很多时候,问题出在你的类别定义本身就不清晰——两个类别之间的视觉边界模糊,再好的模型也救不回来。
4. MATLAB 路线怎么做:Deep Learning Toolbox 的 12 行迁移学习
4.1 imageDatastore 与增强数据流:读图与预处理一步到位
MATLAB 的优雅之处在于数据流很短。imageDatastore同样把目录名映射为标签,augmentedImageDatastore则负责把图像统一缩放成网络输入尺寸、做数据增强,并且不会真的把增强后的图片写回磁盘,内存开销很可控。
% 读入 data 目录,子文件夹名作为标签 imds = imageDatastore(fullfile(pwd, 'data'), ... 'IncludeSubfolders', true, ... 'LabelSource', 'foldernames'); % 按标签 8:2 划分训练和验证 [imdsTrain, imdsVal] = splitEachLabel(imds, 0.8, 'randomized'); % 统一缩放到 ResNet-18 要求的 224x224,并启用随机增强 augTrain = augmentedImageDatastore([224 224], imdsTrain); augVal = augmentedImageDatastore([224 224], imdsVal);这里有个容易被忽略的前提:resnet18默认输入是 224×224×3 的 RGB 图像。如果你的数据是灰度图,augmentedImageDatastore不会自动帮你复制成三通道,训练时会直接报维度不匹配。常见做法是在imageDatastore的'ReadFcn'里写一个匿名函数:读取图片后用repmat把单通道复制成三通道。这个坑在第 5 章还会展开,这里先记住:网络输入尺寸和三通道要求是硬约束,数据的预处理必须迎合它。
4.2 替换 ResNet-18 最后三层,用 trainNetwork 训练
MATLAB 做迁移学习的核心操作是“取预训练网络 → 卸掉顶层 → 换成自己的分类头”。resnet18函数会直接返回一个带 ImageNet 权重的LayerGraph,图层名字是固定的,这是好事,因为替换代码可以写得非常明确。
% 加载预训练 ResNet-18 net = resnet18; lgraph = layerGraph(net); numClasses = numel(categories(imdsTrain.Labels)); newLayers = [ fullyConnectedLayer(numClasses, 'Name', 'fc_new') softmaxLayer('Name', 'softmax_new') classificationLayer('Name', 'output_new') ]; % 替换网络顶部的三处关键层 lgraph = replaceLayer(lgraph, 'fc1000', newLayers(1)); lgraph = replaceLayer(lgraph, 'prob', newLayers(2)); lgraph = replaceLayer(lgraph, 'ClassificationLayer_predictions', newLayers(3)); % 训练选项 options = trainingOptions('sgdm', ... 'MiniBatchSize', 32, ... 'MaxEpochs', 10, ... 'InitialLearnRate', 1e-3, ... 'Shuffle', 'every-epoch', ... 'ValidationData', augVal, ... 'ValidationFrequency', 10, ... 'Plots', 'training-progress', ... 'Verbose', false); netTrained = trainNetwork(lgraph, augTrain, options);这段代码的逻辑是:replaceLayer把原网络最后三层的输出改成新分类头,trainNetwork在训练时只会更新新加的层。但需要明确一点,MATLAB 的trainingOptions里InitialLearnRate=1e-3默认作用在整个可训练参数上,而原 ResNet-18 的卷积层此时仍处于冻结状态,所以这个学习率实际上只影响新分类头,不会冲毁预训练权重。如果之后想解冻部分卷积层,就需要用freezeLayers或逐层设置学习率因子,否则1e-3对预训练层而言直接是灾难。
trainingOptions里另一个实用参数是'Plots', 'training-progress',它会在训练时画出 loss 曲线和验证准确率曲线,作用相当于 TensorBoard,能让你第一时间看到模型是否过拟合。验证频率ValidationFrequency=10表示每 10 次迭代在验证集上评估一次,这个值在小数据集上足够密,在数据量大时也可以适当调大以节省时间。
4.3 用 ONNX 把 MATLAB 和 Python 两边串起来
如果团队的算法原型在 Python 里做完,但交付环境要求 MATLAB,手动重写一遍难免改出不一致。ONNX 是深度学习中跨框架模型交换的事实标准,PyTorch 可以把训练好的模型导出为.onnx,MATLAB 可以直接导入并用于推理。这条链路我已经在多个项目里用过,值得在这里给出最小操作。
# Python 侧导出 import torch dummy = torch.randn(1, 3, 224, 224) torch.onnx.export(model, dummy, "scene_resnet18.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}}, opset_version=13) print("export done")% MATLAB 侧导入并推理 netOnnx = importONNXNetwork('scene_resnet18.onnx', 'OutputLayerType', 'classification'); pred = classify(netOnnx, augVal); acc = mean(pred == imdsVal.Labels); disp(['ONNX imported model val acc: ', num2str(acc)]);dynamic_axes里的配置很关键:它告诉 ONNX 导出器允许 batch 维度可变,否则模型会被固化成一个固定 batch size 的静态图,MATLAB 推理时传入不同数量的图片会直接报 shape 错。opset_version=13是兼容性和算子支持率都比较稳的一个版本,太低可能缺少部分算子,太高则可能超出某些老版本 MATLAB 的支持范围。如果导入时报“不支持某层”,不要急着怪框架,可以先在 MATLAB 中查看analyzeNetwork(netOnnx)给出的网络结构图,确认具体是哪一层不兼容,再用replaceLayer手工替换成等价层。
5. 场景识别避坑记录:从数据翻车到模型黑匣子
5.1 训练集越来越好,验证集纹丝不动:预训练层的学习率太高
现象:训练了 5 个 epoch,训练集准确率从 60% 爬到 95%,验证集准确率却卡在 70% 左右不动,甚至轻微下滑。很多人第一反应是数据不够,于是拼命加数据,结果改善有限。
原因:如果直接对整个网络跑了全量微调,且学习率是1e-3,那预训练卷积层的权重在最初几个 epoch 就被大幅改动。场景数据量远小于 ImageNet,预训练层学到的通用能力被新数据冲垮,模型开始死记训练集的背景纹理,泛化自然失败。
解决:先把卷积层全部冻结,只训练分类头,验证一波;如果准确率不理想,再解冻最后一个残差块,并把这个部分的学习率降到1e-4或1e-5。我常用的做法是给优化器传两组参数:[{'params': base.parameters(), 'lr': 1e-5}, {'params': head.parameters(), 'lr': 1e-3}]。这样既保留了预训练特征,又给了分类头充分的更新空间。
5.2 灰度图遇上传入通道不一致:尺寸对不上从哪里查
现象:Python 侧训练到中途突然报RuntimeError: Given groups=1, weight of size [64, 3, 7, 7], expected input to have 3 channels, but got 1 channels instead;MATLAB 侧则在trainNetwork时报输入层维度不匹配。
原因:数据里有灰度图,读图后自动变成单通道,而 ResNet 第一层卷积要求三通道输入。这在小规模自制数据里非常常见,特别是来自监控视频的帧,很多是灰度格式。
解决:在数据读取入口统一转换,Python 侧在transform里加transforms.Lambda(lambda x: x.repeat(3, 1, 1) if x.size(0) == 1 else x)。MATLAB 侧用自定义ReadFcn,读图后判断size(img, 3),若不是 3 就分别复制到三个通道。最省事的办法是在数据集构建阶段就把所有图片统一转换为 RGB 保存,避免每次训练都在数据流里做兼容处理。这类错误看着很硬核,其实是三个最容易自查的问题之一。
5.3 ONNX 导入 MATLAB 后层不支持或输出全错
现象:Python 导出的 ONNX 文件在 MATLAB 里用importONNXNetwork导入失败,报某个具体算子不支持;偶尔导入成功后,推理结果和 Python 侧不一致,softmax 输出概率全员接近 0.5。
原因:有两个常见来源。一是模型里有动态控制流或非标准层,比如注意力机制中的自定义 reshape,ONNX 导出时找不到直接对应的算子,要么报错要么导出一个不稳定的等价子图。二是 batch 维度没有通过dynamic_axes标记,导入后被固化,MATLAB 推理时输入形状发偏移,类别输出错位。
解决:导出前先把模型固定到推理模式,用model.eval(),再用一份与真实输入相同尺寸的dummy张量导出。遇到算子不支持的层,优先在导出时设置opset_version为 11 或 13,选择更低的一版,很多时候就能绕开新算子带来的问题。如果问题出在自定义层,最可靠的做法是把这个层直接拿掉或替换成等价的组合层,再导出。记住,ONNX 交换模型不是为了追求结构完美,而是为了让两边跑出一致结果,必要时需要对模型做“减法”。
5.4 室内场景互相混淆,是模型专注力不足还是类别定义模糊
现象:混淆矩阵里“厨房”有 30% 被识别成“客厅”,“卧室”和“客厅”也互相串。单独看这些误判图片,人眼也觉得不好分,因为都存在沙发、桌子和木地板。
原因:这里有个很像是玄学的规律:场景识别的判别信息不只来自物体本身,还来自物体间的空间关系。以厨房为例,灶台、水槽、冰箱这三个物体同时出现并形成一个“操作三角区”,才是强证据。而 ResNet-18 这类网络虽然在卷积层有空间结构,但顶层全连接层把特征拉平成向量后,空间位置关系就被压缩了。
解决:第一优先检查数据定义,把“餐厅”和“客厅”合并成“公共活动区”,削弱主观歧义;第二给模型更大的输入视野,把分辨率从 224 提升到 384,同时把RandomResizedCrop的尺度下限调低到 0.3,逼模型看到更大范围的场景;第三,如果项目允许换网络,试一下带位置编码的 Transformer 结构,比如 Swin-T,它对全局布局的建模天然比卷积网络更适合场景识别。这属于典型的“先改数据,再改模型”的排查顺序。
5.5 数据太少,验证集准确率和抽签差不多
现象:每类只有 20 来张图片,训练集准确率很快就到 100%,验证集准确率在 50% 上下抖动,和随机猜差不多。这种情况下,不管怎么调学习率、换优化器都无济于事。
原因:样本量不足以支撑模型学到稳定的类内差异。场景识别的类内差异远大于物体分类,同样是“海滩”,有礁石滩、沙滩、阴天海滩、黄昏海滩,每类 20 张连覆盖基本光照变化都做不到。这也解释了为什么很多项目从“基于深度学习的口腔疾病图像识别系统”这类医学图像任务迁移过来时,都觉得场景识别更难,因为医学图像背景统一、目标位置居中,而自然场景变量太多。
解决:把数据量提到每类 100 张以上是硬道理。没有现成数据时,视频抽帧是效率最高的方式——一段 10 分钟的室内视频,按每 2 秒抽一帧再剔除相似帧,能轻松得到几百张同一场景不同角度的训练图。另一个有效手段是放弃端到端微调,改用“预训练模型提特征 + SVM 分类”的管线:把每张图经过 ResNet-18 得到 512 维特征向量,再训练一个线性 SVM。这个方法在小数据集上往往比全网络微调更稳,也更难过拟合,值得作为备选方案。
6. 从“跑通了”到“能交差”:用 Grad-CAM 验证模型真正看到了什么
6.1 一个不依赖第三方库的 Grad-CAM 小脚本
模型准确率达标不代表模型学到了你想让它学的东西。很多时候准确率靠的是背景、水印、相册边框这些偶然线索。判断模型真正依据什么做出决策,最直观的方法是 Grad-CAM 热力图。它用梯度给最后一个卷积层的每个通道加权,叠加出“模型重点关注的区域”,正好适合验证场景识别这类全局判断任务。
import torch import numpy as np from torchvision import models, transforms from PIL import Image import matplotlib.pyplot as plt model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1).eval() preprocess = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) img = Image.open('val/kitchen/kitchen_013_night.jpg') x = preprocess(img).unsqueeze(0) acts, grads = {}, {} # 挂载 forward/backward hook 捕获最后一层卷积的输出和梯度 model.layer4[-1].register_forward_hook( lambda m, i, o: acts.__setitem__('x', o[0].detach())) model.layer4[-1].register_full_backward_hook( lambda m, gi, go: grads.__setitem__('x', go[0].detach())) out = model(x) target = torch.argmax(out) model.zero_grad() out[0, target].backward() # 通道权重 = 梯度的全局平均池化,再把通道加权后的特征图压缩成单张热力图 weights = grads['x'].mean(dim=(2, 3), keepdim=True) cam = (weights * acts['x']).sum(dim=1, keepdim=True).squeeze(dim=1) cam = torch.relu(cam) cam = cam - cam.min() cam = cam / (cam.max() + 1e-8) cam = torch.nn.functional.interpolate( cam.unsqueeze(0), size=(224, 224), mode='bilinear', align_corners=False) plt.imshow(img.resize((224, 224))) plt.imshow(cam[0].numpy(), cmap='jet', alpha=0.5) plt.axis('off') plt.show()这段代码的逻辑分成三步:前向传播时把layer4最后一个卷积层的特征图存下来;反向传播时把关于目标类别的梯度也存下来;最后用梯度的全局平均作为每个通道的权重,对所有通道的特征图做加权求和并经过 ReLU,得到热力图。alpha=0.5控制热力图叠加透明度,值越大热力图越醒目。
6.2 热力图怎么判读、怎么反向指导数据与模型调整
判读 Grad-CAM 有几个经验。如果模型在“厨房”图上重点关注灶台、水槽和操作台,说明它学到了真正的场景判别特征;如果热力图四散分布在墙面、门框和地面,说明模型还在依赖统计上的颜色或纹理。更严重的情况是热力图集中在这张图片的水印或角落 logo 区域,那基本可以断定训练数据里有不该出现的伪线索,需要立即清洗数据。
我会在每个项目交付前抽 10 到 20 张验证集图片跑一遍 Grad-CAM,把热力图和预测类别打印到一张表里,这个过程比准确率数字更能说明模型有没有“学歪”。以前我做场景识别也热衷于调参,后来发现可视化才是真的后悔药,它能帮你少走很多弯路。希望这篇笔记能让你的场景识别项目从第一步就走对方向,顺利交出一份经得起追问的成果。
本文还有配套的精品资源,点击获取