简介:这是一份基于VGG16与LSTM的视频场景识别Python毕设项目源码,覆盖关键帧选取、特征提取与时序建模完整流程,主要面向计算机、人工智能、通信工程、自动化等专业学生,也适合用作课程设计、毕业设计或项目立项演示。压缩包内共15个文件,其中8个Python脚本分别承担数据加载、模型构建、训练评估与测试等任务,5个pyc为运行时生成的缓存文件可忽略,2个JSON文件保存类别索引与映射信息,整体仅16KB,结构非常轻量。项目现在已有136人学习查看,代码均经运行验证,功能正常,可直接启动或调整模型与数据集扩展为其他视频理解场景。通过源码可以系统学习VGG16空间特征提取与LSTM时间序列建模的结合方式,同时了解关键帧处理、类别映射、数据预处理与推理测试的衔接;项目适合论文复现、课设改造和毕设二次开发,对不同基础的学习者都较友好。
1. 为什么视频场景识别要用 VGG16+LSTM:单帧分类解决不了时序歧义
视频场景识别这个毕设题目,看起来就是图像分类加一个序列头,实际动手才发现,关键帧怎么抽、特征从哪层取、LSTM 的输入到底是什么形状,每一步都能卡住半天。VGG16+LSTM 这个组合在毕设里不算新,但它把一个难题拆成了两个好解决的问题:VGG16 负责回答“这一帧里有什么”,LSTM 负责回答“这些帧按先后顺序发生了什么”,分工明确,代码也好调试。单帧分类之所以不够用,是因为同一帧在不同上下文里意义不同——一段海滩视频里出现一帧人群特写,前面是海浪、后面是遮阳伞,模型靠单帧根本判断不了场景。
这个方案适合手里有几千条短视频、想做场景级别分类——比如海滩、街道、室内课堂、体育场之类——的同学。它不依赖大规模视频预训练模型,一台 6G 显存以上的电脑就能跑完整两阶段训练。接下来的内容按实操顺序展开:先做关键帧采样,再提 VGG16 空间特征,然后让 LSTM 学时序关系,最后是训练中我反复踩过又填平的坑。
2. 关键帧提取:均匀采样与内容感知方案的代码和取舍
2.1 关键帧是什么:视频压缩里的概念,实践中的帧挑选策略
视频里的“关键帧”最早来自压缩编码,指的是 I 帧——它完整存储一帧画面,不依赖前后帧就能解码,而 P 帧和 B 帧只存变化量。很多同学误以为可以用解码器吐出来的 I 帧当关键帧,实际这么做效果很差:视频编码器选 I 帧的间隔取决于编码参数和画面变化,与场景内容没有直接关系,同一段风景视频可能 2 秒才一个 I 帧,另一段动作视频 0.5 秒就一个。
所以“基于关键帧的视频场景识别”落到实现上,通常是自己定采样策略。常见做法有两种:均匀采样,每隔固定帧数取一帧;内容感知采样,用帧间差异挑出画面变化大的帧。毕设里绝大多数情况用均匀采样就够了,因为场景识别关心的是“这一段视频属于哪个场景”,不是“动作变化瞬间在哪里”。均匀采样代码简单、可复现性好,答辩时也容易讲清楚。
import cv2 import numpy as np def extract_keyframes_uniform(video_path, num_frames=16, target_size=(224, 224)): """从视频中均匀抽取 num_frames 个关键帧""" cap = cv2.VideoCapture(video_path) total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) if total <= 0: cap.release() return [] interval = max(total // num_frames, 1) # 每隔 interval 帧抽一帧 frames = [] frame_idx = 0 while len(frames) < num_frames: ret, frame = cap.read() if not ret: break if frame_idx % interval == 0: frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) # VGG16 用 RGB 输入 frame = cv2.resize(frame, target_size) frames.append(frame) frame_idx += 1 cap.release() return frames这段代码的核心参数是num_frames和interval。num_frames=16是视频分类任务里常用的经验值,它兼顾了时序信息的完整性和 LSTM 的训练速度;interval = total // num_frames会把整个视频均匀切分成 16 段,每段中间取一帧,比从头连续取 16 帧更能覆盖视频全貌。需要注意的是cv2.cvtColor这一步,OpenCV 默认读进来是 BGR,而 VGG16 在 ImageNet 上训练时用的是 RGB,不转换的话特征分布会偏。cv2.resize直接缩放到 224×224,因为 VGG16 的卷积部分接受任意尺寸输入后经池化输出固定尺寸,但统一尺寸更好做 batch 训练。
一个细节是interval至少为 1。如果视频总帧数小于num_frames,total // num_frames会得到 0,所以用max(..., 1)兜底。这种短视频会在第 5 章单独讨论处理办法。
2.2 内容感知采样:什么时候值得用帧间差分
均匀采样最怕一种情况:视频很长但场景长时间静止,比如监控画面里一个会议室坐了 40 分钟,均匀抽出来的 16 帧几乎一样,时序信息全部冗余。这时候内容感知采样更合理,常见做法是计算相邻帧的直方图差异,差异超过阈值才作为关键帧保留。
def extract_keyframes_by_diff(video_path, num_frames=16, target_size=(224, 224), diff_threshold=0.25): """基于 HSV 直方图差异的关键帧采样""" cap = cv2.VideoCapture(video_path) prev_hist = None frames = [] frame_idx = 0 while len(frames) < num_frames: ret, frame = cap.read() if not ret: break hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) hist = cv2.calcHist([hsv], [0, 1], None, [50, 60], [0, 180, 0, 256]) cv2.normalize(hist, hist) if prev_hist is None: diff = 1.0 else: diff = cv2.compareHist(prev_hist, hist, cv2.HISTCMP_BHATTACHARYYA) if diff > diff_threshold: frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) frame = cv2.resize(frame, target_size) frames.append(frame) prev_hist = hist frame_idx += 1 cap.release() return framescv2.compareHist用的 Bhattacharyya 距离取值范围是 0 到 1,越大表示两帧差异越大,diff_threshold=0.25是我在户外场景数据集上调出来比较稳的起点值。这个方法的问题是要么挑出的帧数不足 16,要么过于密集,所以你仍然需要兜底逻辑——如果最后不足num_frames,就用均匀采样补足。
对毕设场景识别来说,我一般不建议在这上面花太多时间。场景变化通常不是突变而是渐变,均匀采样引入的一些冗余帧恰好给 LSTM 提供了“场景稳定”的证据,反而是好事。把内容感知采样写进论文的创新点介绍里就够了,实验部分用均匀采样结果即可。
2.3 预处理与 Dataset 封装:让数据变成 [B,T,C,H,W]
关键帧抽出来后,还要做标准化和 Tensor 转换。VGG16 的预训练权重是在 ImageNet 上训的,输入要减去特定均值再除以标准差,这一步不做的话特征分布会整体偏移,后面 LSTM 的训练会异常困难,loss 明明在降但验证准确率停在很低的值。标准参数如下。
from torchvision import transforms frame_transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])这三个 mean 和 std 是 ImageNet 数据集的统计值,所有在 ImageNet 上预训练过的模型都默认这套参数。ToTensor会把 0-255 的 uint8 转成 0-1 的浮点数,再执行减均值除方差。
接下来需要把采样和预处理包装成一个 Dataset,让 DataLoader 能直接产出 batch。这里有一个关键布局问题:模型最终需要的输入形状是[B, T, C, H, W],B 是视频条数,T 是关键帧数量,C 是通道数,H 和 W 是分辨率。
import torch from torch.utils.data import Dataset class VideoSceneDataset(Dataset): def __init__(self, video_paths, labels, num_frames=16, transform=None): self.video_paths = video_paths self.labels = labels self.num_frames = num_frames self.transform = transform def __len__(self): return len(self.video_paths) def __getitem__(self, idx): frames = extract_keyframes_uniform( self.video_paths[idx], self.num_frames ) frames = torch.stack([self.transform(f) for f in frames]) # frames: [T, C, H, W] return frames, self.labels[idx]DataLoader 默认会把[T, C, H, W]叠成[B, T, C, H, W]。注意torch.stack里逐帧调transform,比整段视频先转 Tensor 再切片要稳,因为ToTensor对单张图的操作是确定的,对 4D 张量也能处理但不容易排查问题。
3. 用 VGG16 提取空间特征:网络结构、取层位置与冻结策略
3.1 VGG16 网络结构详解:13 个卷积层与 3 个全连接层的分工
VGG16 的名字里“16”指的是带权重的层数:13 个卷积层加 3 个全连接层。它没有花哨的结构,全部用 3×3 卷积加 2×2 最大池化堆叠,靠增加深度换表达能力。因为结构规整,它的实现和调试成本在预训练模型里几乎是最低的,这也是毕设选它的主要原因。
| 区块 | 结构 | 输出尺寸 | 参数量 |
|---|---|---|---|
| block1 | conv3-64 ×2 + maxpool | 112×112×64 | 约 4 万 |
| block2 | conv3-128 ×2 + maxpool | 56×56×128 | 约 22 万 |
| block3 | conv3-256 ×3 + maxpool | 28×28×256 | 约 110 万 |
| block4 | conv3-512 ×3 + maxpool | 14×14×512 | 约 354 万 |
| block5 | conv3-512 ×3 + maxpool | 7×7×512 | 约 708 万 |
| 分类器 | 全连接 25088→4096→4096→1000 | 1000 | 约 123 万 |
表格里卷积部分参数量加起来才 1200 万左右,而全连接层接近 1.03 亿,占了整个网络的绝大部分。这带来一个结论:做视频场景识别时,如果只是用 VGG16 提空间特征,把后三个全连接层整个去掉,模型参数量直接缩到原来的十分之一,前向传播速度快很多,内存占用也小得多。像素级特征已经被卷积层提取完毕,全连接层只是把特征映射到 1000 个 ImageNet 类别,对场景识别没有保留价值。
3.2 取哪一层的输出:从 25088 维降到 512 维
torchvision 里加载 VGG16 默认结构是features + avgpool + classifier,其中avgpool是AdaptiveAvgPool2d((7, 7)),也就是卷积部分输出 512×7×7,展平后是 25088 维。如果直接用这个维度接 LSTM,意味着每个时间步要喂进去 25088 个数,LSTM 的参数量会膨胀到不可收拾。
常见做法是在卷积部分后面再接一个AdaptiveAvgPool2d((1, 1)),把空间维度压成 1×1,得到 512 维特征向量。
import torch import torch.nn as nn from torchvision.models import vgg16, VGG16_Weights class VGGSpatialEncoder(nn.Module): def __init__(self, freeze_all=True): super().__init__() # 新版 torchvision 用 weights 参数,旧版是 pretrained=True self.vgg = vgg16(weights=VGG16_Weights.DEFAULT) # 保留卷积特征部分,换成全局平均池化 self.backbone = nn.Sequential( self.vgg.features, # 卷积 + 池化,输出 [B, 512, 7, 7] nn.AdaptiveAvgPool2d((1, 1)), # 压到 [B, 512, 1, 1] nn.Flatten() # 展平为 [B, 512] ) if freeze_all: for param in self.backbone.parameters(): param.requires_grad = False def forward(self, x): return self.backbone(x)关键在nn.AdaptiveAvgPool2d((1, 1))这一层。它把 VGG16 卷积部分输出的 512×7×7 在空间维度做平均池化,变成一个 512 维向量,相当于保留了“这张图里有哪些视觉概念”的信息,而丢弃了它们在画面中的具体位置。对场景识别来说,位置信息本来就不是重点——沙滩在画面上方还是下方,不影响它是沙滩。freeze_all=True表示整个卷积部分不参与反向传播,只把它当特征提取器用。
为什么不直接取 4096 维的全连接层输出?全连接层确实包含更多语义特征,但 4096 维会让 LSTM 的输入维度变大 8 倍,训练时间翻几番,而场景识别数据量通常只有几千条视频,大维度反而更容易过拟合。512 维是我用下来性价比最高的值。
3.3 冻结与微调策略:先冻结 VGG16 只练 LSTM
冻结策略取决于数据集规模。视频场景识别毕设数据量通常在几百到几千条,离训练 VGG16 需要的百万级数据差得很远,所以默认必须冻结。常见的参数配置是:VGG16 全部冻结,只训练 LSTM 和最后的全连接分类器;如果数据量到一万条以上,再考虑解冻 VGG16 的 block4 和 block5,用lr=1e-5微调。
class SpatialTemporalPipeline(nn.Module): def __init__(self, spatial_encoder, temporal_model): super().__init__() self.spatial = spatial_encoder self.temporal = temporal_model def forward(self, x): B, T, C, H, W = x.shape # 把 batch 和时序维度合并,一次性过 VGG16 x = x.view(B * T, C, H, W) feat = self.spatial(x) # [B*T, 512] feat = feat.view(B, T, -1) # 拆回 [B, T, 512] return self.temporal(feat)养成先冻结、后微调的习惯,能在不牺牲效果的前提下省掉大量调参时间。冻结后 VGG16 部分每次 forward 只计算一次,不会参与梯度更新,反向传播不需要为它保存中间激活值,显存占用会显著下降。训练时检查网络里哪些参数在更新,最直接的办法是[p.requires_grad for p in model.parameters()],确认只有 LSTM 部分是 True。
3.4 特征缓存:把 VGG16 的输出存成 npy 再训练 LSTM
如果你用的是老电脑,或者想让训练速度从小时级降到分钟级,有一个值得做的优化:先把所有视频的 VGG16 特征离线算好,存成 npy 文件,再用这些特征训练 LSTM。这样每个 epoch 都不再需要跑 VGG16 的卷积计算,LSTM 训练只需要 CPU 都能跑。
import numpy as np import torch def extract_and_cache_features(dataset, spatial_encoder, cache_path): """离线提取 VGG16 特征并缓存""" spatial_encoder.eval() all_feats, all_labels = [], [] with torch.no_grad(): for frames, labels in dataset: frames = frames.unsqueeze(0) # [1, T, C, H, W] B, T, C, H, W = frames.shape feat = spatial_encoder(frames.view(B * T, C, H, W)) feat = feat.view(B, T, -1).squeeze(0) # [T, 512] all_feats.append(feat.numpy()) all_labels.append(labels.item()) np.save(cache_path, np.array(all_feats)) # 同时保存标签 np.save(cache_path.replace('.npy', '_labels.npy'), np.array(all_labels))这步做完,训练脚本里读 npy 构造 TensorDataset 就可以,LSTM 的输入直接从[B, T, 512]开始。实践中这段缓存代码能省掉 70% 以上的训练时间,同时避免后面第 5 章说的显存溢出问题。第一次跑缓存慢,之后每次训练都是秒开。
4. LSTM 时序建模与训练:从输入形状到完整训练循环
4.1 nn.LSTM 的参数:hidden_size、num_layers、batch_first 与 dropout
LSTM 在视频场景识别里的角色是把 VGG16 输出的 16 个 512 维特征按时间顺序读完,在学习每个位置特征的同时记住前面帧的上下文。它的核心参数就五个,调明白整个模型就通了一半。
| 参数 | 含义 | 推荐值 | 说明 |
|---|---|---|---|
| input_size | 每个时间步的特征维度 | 512 | 必须与 VGG16 输出维度一致 |
| hidden_size | 隐状态维度 | 256 | 太小欠拟合,太大过拟合 |
| num_layers | LSTM 堆叠层数 | 2 | 超过 3 层在视频任务中容易梯度消失 |
| batch_first | 输入是否 batch 在前 | True | PyTorch 默认 batch 在中间,设了省心 |
| dropout | 层间 dropout | 0.3 | 只在 num_layers > 1 时生效 |
input_size=512对应 VGG16 每帧的特征维度;hidden_size=256是隐状态宽度,它会同时决定最后分类层的输入维度;num_layers=2让模型有时间层次感——第一层捕捉短程帧间关系,第二层在更高层抽象上建模更长依赖。dropout 只加在多层的层间连接上,单层 LSTM 设 dropout 参数会被 PyTorch 直接忽略,这是容易踩的坑。
模型的输出有两种取法:out[:, -1, :]取最后一个时间步,或者out.mean(dim=1)对所有时间步的输出做平均。场景识别建议用平均池化,因为最后一个时间步只代表视频结尾那一刻的信息,而场景判断需要全局证据,平均池化能把每个关键帧的贡献都纳入。
4.2 把 VGG16 和 LSTM 串成完整模型:batch 合并与拆分的细节
端到端模型的结构是把第 3 章的空间编码器和第 4 章的 LSTM 拼起来,难点在于 batch 维度的合并与拆分。VGG16 期望输入是[B, C, H, W],而 LSTM 期望输入是[B, T, input_size],中间必须经历一次 4D 转 5D 再转回 3D 的过程。
import torch import torch.nn as nn class VGG16LSTM(nn.Module): def __init__(self, num_classes=10, input_dim=512, hidden_size=256, num_layers=2, dropout=0.3): super().__init__() self.spatial = VGGSpatialEncoder(freeze_all=True) self.lstm = nn.LSTM( input_size=input_dim, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0.0 ) self.classifier = nn.Sequential( nn.Linear(hidden_size, 128), nn.ReLU(), nn.Dropout(p=dropout), nn.Linear(128, num_classes) ) def forward(self, x): # x: [B, T, C, H, W] B, T, C, H, W = x.shape # 合并 batch 和时序,过 VGG16 x = x.view(B * T, C, H, W) # [B*T, C, H, W] feat = self.spatial(x) # [B*T, 512] # 拆分回原形状,进入 LSTM feat = feat.view(B, T, -1) # [B, T, 512] lstm_out, _ = self.lstm(feat) # [B, T, hidden_size] # 对所有时间步做平均,得到视频级特征 video_feat = lstm_out.mean(dim=1) # [B, hidden_size] return self.classifier(video_feat)最需要理解的两行是x.view(B * T, C, H, W)和feat.view(B, T, -1)。如果不合并,一个 batch 里 8 条视频、每条 16 帧,就要循环 128 次前向传播,速度会慢到无法接受。合并后一次 VGG16 forward 处理全部 128 帧,然后按原来的 B、T 拆回,LSTM 才能区分“这是 8 条视频、每条 16 帧”。video_feat = lstm_out.mean(dim=1)这一步会把 16 个时间步的信息压缩成一个 256 维向量,再交给分类器。
4.3 训练循环与超参:Adam、CrossEntropy、学习率与 clip_grad
训练配置直接影响收敛速度和最终准确率。我的默认组合是 Adam + 交叉熵 + 1e-4 学习率 + 1.0 梯度裁剪。视频数据每条样本包含 16 帧,batch_size 通常只能设到 8,这是显存限制下的实际经验值。
import torch.optim as optim from torch.utils.data import DataLoader model = VGG16LSTM(num_classes=10) optimizer = optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-4, weight_decay=1e-5) criterion = nn.CrossEntropyLoss() scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', patience=3, factor=0.5) train_loader = DataLoader(train_dataset, batch_size=8, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=8, shuffle=False) for epoch in range(30): model.train() for frames, labels in train_loader: frames = frames.to(device) labels = labels.to(device) optimizer.zero_grad() outputs = model(frames) loss = criterion(outputs, labels) loss.backward() # 梯度裁剪:防止 LSTM 梯度爆炸 nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() # 验证 model.eval() val_loss = 0.0 with torch.no_grad(): for frames, labels in val_loader: frames, labels = frames.to(device), labels.to(device) outputs = model(frames) val_loss += criterion(outputs, labels).item() val_loss /= len(val_loader) scheduler.step(val_loss)filter(lambda p: p.requires_grad, ...)确保冻结的 VGG16 参数不进入优化器,这一步很重要,冻结参数如果放在 Adam 里,优化器依然会为它们维护动量缓存,既占内存又可能在某些实现中触发错误。weight_decay=1e-5是 L2 正则,对少量数据防止过拟合有实际帮助。clip_grad_norm_梯度裁剪是我必加的,LSTM 在时间维展开 16 步,反向传播的梯度范数可能达到几百甚至上千,不裁剪时 loss 会在某个 epoch 突然跳到 nan,裁剪到 1.0 后这个现象基本消失。
关于学习率调度器,偷懒的说法是 Adam 可以不配调度器,但视频数据分布差异大,验证 loss 降到一个平台后很难自己突破。用ReduceLROnPlateau,patience=3 的意思是连续 3 个 epoch 验证 loss 不降就把学习率减半,一般在第 15 到 25 个 epoch 时会见效。
5. 避坑:关键帧、数据泄露与显存管理的 5 个典型问题
5.1 短视频抽不满关键帧导致训练报错
现象:程序跑第一个 epoch 就崩,报错信息通常是stack expects each tensor to be equal size,或者 Loss 直接变成 nan。
原因:视频总帧数少于num_frames,均匀采样时 while 循环还没取满 16 帧就break了。Dataset 返回的帧数不一致,DataLoader 无法把长度不同的张量叠成 batch。更隐蔽的情况是视频只有 5 帧,linspace(0, total-1, 16)产生的索引重复取同一帧,虽然张量形状是齐的,但模型学到一堆重复画面。
解决:在extract_keyframes_uniform里判断len(frames) < num_frames时用最后一帧反复补齐。这样既保证返回帧数一致,又不会引入无效黑帧,因为最后一个画面通常对场景判断仍有参考价值。
# 在上面的采样函数 return 前补一段 while len(frames) < num_frames: if frames: frames.append(frames[-1]) # 复制最后一帧补齐 else: return [] # 视频真的一帧都没有,直接跳过5.2 LSTM 序列过长导致梯度消失,num_frames 不是越多越好
现象:验证准确率在第 10 个 epoch 之后几乎不动,训练 loss 还在缓慢下降,典型过拟合前兆里还混着“模型根本学不动”的味道。把num_frames从 16 提到 48 后,问题反而更严重。
原因:LSTM 按时间步展开,序列越长反向传播路径越长,梯度消失的概率越大。48 帧对 VGG16 来说不过是多算 32 次卷积,但对 LSTM 来说是从 16 步跨到 48 步,远超出两层 LSTM 在中小数据集上能稳定建模的长度。
解决:不要盲目增加关键帧数。首先把num_frames固定在 16,然后加梯度裁剪clip_grad_norm_(max_norm=1.0)。如果确实需要更长时序覆盖,用双向 LSTM 或把帧数加到 24 同时把 LSTM 层数降到 1 层。毕设场景下 16 帧是我反复验证过最稳的值。
5.3 同一个视频片段同时进了训练集和验证集
现象:验证准确率出奇地高,比如 97% 以上,但测试集换一批视频后准确率掉到 60%。训练曲线和验证曲线几乎重合。
原因:数据划分时按帧切分而不是按视频切分。比如把每段视频按比例切成两半,前一半进训练集、后一半进验证集,相邻关键帧高度相似,模型等于“见过答案再考试”。这种问题在视频任务里叫 temporal leakage,是毕设中最容易被低估的翻车点。
解决:划分数据集前先把视频路径按场景分组,再用train_test_split的stratify参数按标签分层,保证同一视频的所有帧只出现在一个集合里。
from sklearn.model_selection import train_test_split video_paths = [...] # 每条视频一个路径 labels = [...] # 每个视频一个标签 train_paths, val_paths, train_labels, val_labels = train_test_split( video_paths, labels, test_size=0.2, stratify=labels, random_state=42 )5.4 显存溢出:VGG16 一次吞掉所有关键帧
现象:RuntimeError: CUDA out of memory,发生在第一个训练 step 的 forward 阶段。6G 显存的卡跑batch_size=8、num_frames=16时必现。
原因:代码里x.view(B*T, C, H, W)把 8×16=128 帧一起送进 VGG16,每帧是 224×224×3,一次前向计算的中间激活值巨大。VGG16 虽然结构老,但卷积层数多,显存峰值远高于同代 ResNet。
解决:三个手段按顺序使用。第一,batch_size降到 4 或 2;第二,用第 3.4 节的特征缓存方案,先算好 npy 再训练 LSTM;第三,如果一定要端到端,开启torch.utils.checkpoint对 VGG16 做梯度检查点,用计算换显存。最推荐第二方案,因为毕设实验往往要跑很多组参数,缓存一次能覆盖后续全部实验。
5.5 VGG16 输入没做标准化,LSTM 训练不收敛
现象:训练 loss 从 2.3 附近开始,下降非常缓慢,20 个 epoch 后还在 2.0 以上。验证准确率始终在类别数分之一附近徘徊。
原因:VGG16 的预训练权重期望输入分布接近 ImageNet(均值约 0.485、方差约 0.229),如果直接喂原始像素值,卷积层输出的特征数值整体偏大,LSTM 的 sigmoid 和 tanh 在饱和区梯度微小,学习几乎停滞。
解决:严格按第 2.3 节的Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])做预处理。如果用的是自己实现的 Dataset,检查transform.to_tensor()之后是否进了Normalize,这一步经常因为封装路径不同被漏掉。核对方法极其简单:打印一条输进模型的 tensor,查看均值和方差是否接近 0 和 1。
6. 验证模型真的记住场景:混淆矩阵、置信度与帧注意力
6.1 按类别召回率看模型短板
整体准确率在类别均衡时有一定参考价值,但场景识别里类别天然不均衡——海滩视频特征鲜明,室内教室视频容易和会议室混淆。用混淆矩阵按类别展开,才能发现模型具体是哪两个场景分不开。
from sklearn.metrics import confusion_matrix, classification_report y_true = [] y_pred = [] model.eval() with torch.no_grad(): for frames, labels in val_loader: frames = frames.to(device) outputs = model(frames) # [B, num_classes] preds = outputs.argmax(dim=1).cpu().numpy() y_pred.extend(preds) y_true.extend(labels.numpy()) print(classification_report(y_true, y_pred, target_names=['beach', 'city', 'classroom', 'stadium', ...]))classification_report会逐类给出精确率、召回率和 F1,召回率低的类别就是模型容易认漏的。常见结果是“教室”和“会议室”互相混,因为 VGG16 提取的桌面、投影幕布视觉特征高度相似。遇到这种情况,优先检查的关键帧里是否真的包含能区分两者的信息,比如黑板、课桌椅这类物件。
6.2 给 LSTM 加上帧注意力:看模型在关注哪些关键帧
LSTM 输出的是视频级特征,但无法回答“模型依据哪些帧做判断”。在分类头前面加一个简单的注意力层,让每个时间步的输出加权求和,同时把权重可视化,这是毕设答辩里最容易出彩的部分。注意力层的实现不复杂,本质是对 LSTM 的 16 个时间步输出学习一组权重。
class AttentionPooling(nn.Module): def __init__(self, hidden_size): super().__init__() self.attn = nn.Linear(hidden_size, 1) def forward(self, lstm_out): # lstm_out: [B, T, hidden_size] scores = self.attn(lstm_out).squeeze(-1) # [B, T] weights = torch.softmax(scores, dim=1) # [B, T] context = torch.bmm(weights.unsqueeze(1), lstm_out).squeeze(1) # [B, hidden_size] return context, weights训练结束后,取一段测试视频,把weights输出跟对应帧画在一张图上,就能直观看到模型把高权重给了哪些画面。我在自己数据集上观察到的规律是:模型倾向于把注意力放在场景标志物体出现的帧上,场景切换点的帧权重也偏高,而纯天空、纯地面的过渡帧权重低。这个现象用来佐证“模型确实在学场景语义”很有说服力。训练时把lstm_out.mean(dim=1)换成AttentionPooling结果,分类头保持不变,准确率通常能再提升 1 到 3 个百分点。
6.3 一个常用的收尾习惯
做这类项目的最后阶段,我的习惯是固定住数据划分和预处理,只调 LSTM 相关参数,跑一组对比实验,把 uniform 采样帧数、hidden_size、dropout 三个变量各试三两档,记录每组的验证准确率和混淆矩阵。这样答辩被问到“为什么选这个参数”时,手里有实际数据支撑而不是凭感觉。最后一句经验:视频场景识别这个方向真正的收益不在模型结构多新,而在数据处理的严谨程度——帧数一致性、数据集划分、输入标准化,这三件事做好了,准确率下限就有了。希望帮到你。
本文还有配套的精品资源,点击获取