简介:面向计算机相关专业学生与毕设开发者,这份项目源码围绕基于关键帧的视频场景识别任务,采用VGG16与LSTM结合的技术路线,实现从视频关键帧提取特征并完成场景分类的完整流程,适合用于毕设、课设或人工智能入门进阶。压缩包共15个文件,包含8个Python脚本、5个编译后的pyc文件及2个JSON配置文件;py文件覆盖数据读取、模型构建、训练与测试等环节,JSON用于类别索引映射,整体仅16KB,结构精简,便于快速阅读和二次修改。目前已有136人学习下载,代码经过运行验证。下载后可从README入手,对照VGG16LSTM.py和my_dataset.py理解模型与数据组织方式,借助test.py查看推理效果;对于希望快速搭建视频识别Demo、完成课程展示或准备答辩演示的读者,这套源码提供了可复用的模块划分与基础实现,便于替换数据后继续扩展。
1. 视频场景识别为什么绕不开 VGG16-LSTM 和关键帧
监控视频分类、短视频内容理解、自动驾驶对路况片段的切分,这类任务要输出的是“这段视频属于哪个场景”,而不是识别单帧里的物体。用 VGG16-LSTM 做视频场景识别,业界最常见的一套做法就是:先抽关键帧,再用 VGG16 提空间特征,把整个时间序列交给 LSTM 建模,最后输出场景标签。对毕设和中小型项目来说,这个方案算力要求不高、代码量可控,而且预训练权重容易拿到,是性价比最高的起点之一。这篇文章会把关键帧怎么抽、网络怎么搭、训练时要注意什么,一步步拆开讲清楚。
2. 关键帧提取:先给视频做减法,把帧差分写成可复现脚本
视频直接丢给神经网络是不现实的。一个 10 秒 25fps 的视频就有 250 帧,如果每帧都过一遍 VGG16,训练和推理的时间都要翻好几倍,而且连续帧之间内容高度冗余。关键帧的作用就是去掉这些冗余,把视频压缩成一个仍然保留场景变化的帧序列。
2.1 三种关键帧提取方式怎么选
常见的关键帧提取方案有三种:均匀采样、帧间差分、聚类抽取。
均匀采样就是每隔固定帧数取一帧,实现最简单,但对镜头切换频繁的视频不友好,可能把精彩的转折帧漏掉。聚类抽取是把所有帧的特征聚成几类,每一类中心作为关键帧,效果不错,不过要先对每帧算特征,计算成本偏高。帧间差分是相邻帧或与上一关键帧做像素差,超过某个阈值就保留当前帧,速度和效果比较均衡,也是我一般会给学生推荐的方案。
在这个任务里,我倾向于把帧间差分作为主方案,均匀采样作为兜底。阈值定得好,一个 20 秒的视频可以压到 15~30 帧,足够 LSTM 学到时间顺序,又不会让 VGG16 的推理时间失控。阈值定不好,视频会被压成几帧,模型基本退化成单帧分类。
2.2 帧间差分脚本与参数说明
下面这段代码用 OpenCV 实现基于关键帧差分的提取逻辑,思路是:当前帧和上一个被选中的关键帧算灰度平均绝对差,超过阈值就选为新关键帧,同时通过最小间隔避免在同一镜头里连续选帧。
import cv2 def extract_keyframes(video_path, threshold=0.35, min_gap=12): cap = cv2.VideoCapture(video_path) keyframes = [] keyframe_gray = None idx = 0 while True: ret, frame = cap.read() if not ret: break gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) gray = cv2.resize(gray, (224, 224)) if keyframe_gray is None: # 第一帧无条件作为关键帧 keyframes.append((idx, frame)) keyframe_gray = gray else: diff = cv2.absdiff(keyframe_gray, gray) score = diff.mean() / 255.0 if score >= threshold and idx - keyframes[-1][0] >= min_gap: keyframes.append((idx, frame)) keyframe_gray = gray idx += 1 cap.release() return keyframes这段代码有几个参数需要解释。threshold 是决定“画面变化多少才算新场景”的阈值,0.35 是一个经验起点,实际使用要看视频类型。场景相对静止的教室录课,背景固定、画面变化小,阈值可以降到 0.25;户外街景或体育比赛,镜头一直在动,阈值要抬到 0.45 以上。min_gap 是两帧关键帧的最小间隔,防止快速闪动的画面导致关键帧扎堆,12 表示至少要隔 12 帧。
注意,这里比较的是当前帧和上一个关键帧,而不是相邻两帧。这么做有讲究:镜头缓慢平移时,相邻两帧差异不大,但累计一段时间后场景已经变了很多。如果用相邻帧差分,这种缓慢变化很容易被当成“没有变化”而漏掉;和上一个关键帧比较,就能在累计差异足够大时选出新关键帧。不过这种基于灰度的差分对光线突变很敏感,一个闪电或者灯光切换会让分数猛增,把突然变亮的帧当成关键帧。想更稳一点,可以改成 HSV 空间的直方图相似度,对光照变化更鲁棒。
提示:如果视频比较长,建议先抽前 500 帧算一个差分分数的分布,用 70~80 分位做阈值,而不是直接拍脑袋定 0.35。后面避坑章节会再展开。
提取出来的关键帧建议直接落盘成 jpg 图片,按视频名建文件夹。训练时不用再解视频,既省 CPU 又能断点续跑。保存时顺便把帧号写进文件名,方便回溯“这个关键帧在原始视频的哪一秒”。
3. 用 VGG16 提空间特征、用 LSTM 串时间关系:模型怎么搭
关键帧抽好了,接下来就是核心的 VGG16-LSTM 模型。这一步要解决两件事:单帧画面里有什么内容,以及这些内容按时间顺序发生了什么变化。
3.1 VGG16 为什么在毕设里是最常见的搭配
先回答一个必须面对的问题:ResNet、EfficientNet 不都比 VGG16 更好吗,为什么大家都在用 VGG16-LSTM?因为视频场景识别真正吃时间的部分是 LSTM 和训练流程本身的调试,backbone 选一个结构透明、权重好拿的模型,能帮你少踩很多坑。VGG16 在 ImageNet 预训练权重随处可得,输入输出尺寸规整,卷积层后接 AdaptiveAvgPool 就能得到 512 维特征向量,和 LSTM 的 input_size 对接非常直接,几乎不会出现维度对不上这种莫名其妙的问题。
ResNet50 的残差结构确实更强,但和 LSTM 拼接时,要处理 BatchNorm 的统计信息、要决定从哪一层抽特征,这些都会增加调试成本。对毕设级别的项目,ViT 和 SlowFast 那些更复杂的时序模型就更不用考虑了,训练时间长、显存需求大,很多机器根本跑不动。用 VGG16-LSTM 先跑通一条完整基线,拿到一个不错的结果,再去换更强的 backbone,这才是好的工程顺序。
3.2 PyTorch 实现:冻结卷积基座,让 LSTM 自己学时序
下面这段代码定义了一个完整的 VGG16-LSTM 模型,backbone 使用 ImageNet 预训练权重,默认冻结全部参数,只训练 LSTM 和最后的分类头。
import torch import torch.nn as nn from torchvision.models import vgg16, VGG16_Weights class VGG16LSTM(nn.Module): def __init__(self, num_classes, hidden_size=256, num_layers=2, bidirectional=False): super().__init__() base = vgg16(weights=VGG16_Weights.IMAGENET1K_V1) # 去掉 VGG16 原来的全连接头,保留卷积特征提取部分 self.backbone = nn.Sequential( *list(base.features.children()), nn.AdaptiveAvgPool2d((1, 1)) ) # 冻结 backbone,初始阶段完全当成特征提取器用 for p in self.backbone.parameters(): p.requires_grad = False self.lstm = nn.LSTM( input_size=512, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=0.3 if num_layers > 1 else 0.0, bidirectional=bidirectional ) out_dim = hidden_size * 2 if bidirectional else hidden_size self.classifier = nn.Linear(out_dim, num_classes) def forward(self, x): # x 形状: [batch, seq_len, 3, 224, 224] batch, seq, c, h, w = x.size() x = x.view(batch * seq, c, h, w) feat = self.backbone(x) # [batch*seq, 512, 1, 1] feat = feat.view(batch, seq, 512) out, _ = self.lstm(feat) # [batch, seq, hidden] out = out[:, -1, :] # 取最后一个时刻的隐状态 return self.classifier(out)这里有几个设计要点要说明。首先,VGG16 的 features 部分输出是 7×7×512 的特征图,后面接的 AdaptiveAvgPool2d 会把它压成 1×1×512,也就是说每帧画面被压缩成一个 512 维的向量,代表这个画面的“内容摘要”。这个 512 就是 LSTM 的 input_size,两边严丝合缝。
LSTM 层用的是 nn.LSTM,hidden_size 取 256,num_layers 取 2。取最后一个时刻的隐状态作为整个视频片段的表征,这是视频场景识别里最直接的做法。但我不太建议把双向和 2 层同时打开,参数会翻倍,小数据集上很容易过拟合。如果要加表达能力,优先把 hidden_size 提到 384,而不是加层或加双向。
如果后续要微调 backbone,可以解锁最后两层的参数,把学习率降到 1e-5 左右。注意,一旦解冻 VGG16,BatchNorm 层的统计信息会被更新,训练时如果 batch_size 太小,均值方差抖动会很厉害。所以解冻微调时,batch_size 至少不要低于 16,否则 loss 会像过山车一样。
4. 数据组织与训练流程:从图片序列到可训练样本
模型结构定了,接下来要把关键帧图片组织成 PyTorch 能读的数据集,然后跑通训练循环。一个常见的做法是:先把所有视频的关键帧提取完,存成图片目录,然后用一个自定义 Dataset 在训练时动态读取并组装成序列。
4.1 数据集结构与 VideoDataset 实现
数据集目录建议这样组织:
- data/train/video_001/0001.jpg
- data/train/video_001/0002.jpg
- data/train/video_002/0001.jpg
每个视频文件夹代表一个样本,标签放到一个 CSV 或字典文件里,一个视频对应一个场景标签。下面的 Dataset 会从一个视频文件夹里均匀抽出 seq_len 帧,弹性和可控性都比读原始视频好。
import os import numpy as np import torch from torch.utils.data import Dataset from PIL import Image from torchvision import transforms class FrameSequenceDataset(Dataset): def __init__(self, root_dir, labels_dict, seq_len=16): self.samples = [] self.seq_len = seq_len self.transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] ), ]) for video_id, label in labels_dict.items(): frame_dir = os.path.join(root_dir, video_id) if os.path.isdir(frame_dir): frame_names = sorted(os.listdir(frame_dir)) self.samples.append((frame_names, frame_dir, label)) def __len__(self): return len(self.samples) def __getitem__(self, idx): frame_names, frame_dir, label = self.samples[idx] # 均匀抽 seq_len 帧,帧数不足时用最后一帧补齐 indices = np.linspace(0, len(frame_names) - 1, self.seq_len).astype(int) frames = [] for i in indices: img = Image.open(os.path.join(frame_dir, frame_names[i])).convert("RGB") frames.append(self.transform(img)) return torch.stack(frames), torch.tensor(label, dtype=torch.long)这个 Dataset 的核心思路是均匀抽样,而不是取前 N 帧。均匀抽样能保证哪怕视频被压缩成 30 帧,LSTM 也能看到开头、中间、结尾三个阶段的画面。如果视频关键帧不足 16 帧,linspace 会重复最后一帧,这是最简单的补齐策略,效果在这个任务里够用。
Normalize 的均值和标准差用的是 VGG16 在 ImageNet 上预训练时的标准值,不要随便改成 0 和 1,否则预训练权重的作用会大打折扣。另外一个容易被忽略的点是 torch.stack 会把所有帧 tensor 叠成 [seq_len, 3, 224, 224],这个形状和模型的 forward 正好对应。
4.2 训练循环、学习率与冻结/微调策略
模型和数据集都就绪后,训练循环可以按下面的代码来写。优化器用 Adam,初始学习率 3e-4,backbone 冻结的情况下这个学习率足够。
import torch.nn as nn from torch.utils.data import DataLoader device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = VGG16LSTM(num_classes=5).to(device) train_loader = DataLoader(train_dataset, batch_size=8, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=8, shuffle=False) optimizer = torch.optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lr=3e-4 ) criterion = nn.CrossEntropyLoss() scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5) for epoch in range(30): model.train() total_loss = 0.0 for frames, labels in train_loader: frames = frames.to(device) # [batch, seq_len, 3, 224, 224] labels = labels.to(device) logits = model(frames) loss = criterion(logits, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() scheduler.step() print(f"epoch {epoch:02d}, loss {total_loss / len(train_loader):.4f}")这里 batch_size 指的是“一次喂给模型的视频片段数”,而不是帧数。每个样本都包含 16 帧关键帧,所以一个 batch 实际会过 VGG16 的图片数是 8×16=128 张。如果显存吃紧,优先调低 batch_size,比如从 8 降到 4,而不是去改 seq_len。
学习率调度用 StepLR 每 10 个 epoch 减半。冻结 backbone 的训练一般 15 个 epoch 就能看到收敛,如果 loss 降得很慢,先检查是不是数据预处理的问题,不要急着换模型结构。等模型在验证集上过了基线,再解冻 VGG16 的最后两层做微调,学习率设 1e-5,只优化解冻部分。我通常会在微调阶段把关掉的学习率调度器重新打开,不然微调后期容易出现 loss 震荡。
提示:首次跑训练,不要直接上全部数据,先拿一个 video_id 的单样本测试 forward 前向传播能否跑通,再开一轮小 epoch 的临时训练,能省下大量排错时间。
5. 评估指标与避坑检查单:四条血泪经验
模型训练完,不能只看准确率就说“成了”。视频场景识别里,类别不均衡和数据泄漏问题特别常见,要建立一个科学的评估视角。
5.1 指标:准确率之外至少看三个数
准确率在数据倾斜的时候会骗人。比如 1000 条视频里 800 条是“教室”,模型全预测“教室”也有 80% 准确率。所以至少要同时看精确率、召回率和 F1 值,并打印混淆矩阵。用 sklearn 的 classification_report 是最快的路径:
from sklearn.metrics import classification_report, confusion_matrix import numpy as np all_preds, all_labels = [], [] model.eval() with torch.no_grad(): for frames, labels in val_loader: frames = frames.to(device) logits = model(frames) preds = logits.argmax(dim=-1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) print(classification_report(all_labels, all_preds)) print(confusion_matrix(all_labels, all_preds))看混淆矩阵时,要特别找两类长期互相错分的场景。比如“演唱会”和“体育场”都有人多、嘈杂、灯光变化的特征,如果这两类分不清,说明关键帧里缺少能区分它们的细节信息,这时候加帧数或换 backbone 才是对症下药。
5.2 避坑清单:四条建议
第一,阈值拍脑袋导致关键帧数量失控。现象:某个视频只抽到 3 帧,另一个视频抽了 200 帧,LSTM 对序列长度极不敏感,模型效果忽高忽低。原因:不同视频的画面差异速度完全不同,固定阈值不可能适配所有视频。解决:写一段脚本统计所有视频的差分分数,计算每个视频阈值的 70 分位,按每个视频单独算阈值。
第二,同一个视频的关键帧同时出现在训练集和验证集。现象:训练准确率 95%,验证准确率 80%,但觉得模型没大问题。原因:数据划分按帧或按图片随机切,同一个视频的不同关键帧分别流进了训练和验证。解决:必须按视频 ID 分组划分,用 sklearn 的 GroupShuffleSplit 保证同一个视频的所有关键帧只出现在一个集合里。
第三,LSTM 输入序列长度不一致时采用强行截断,把后半段内容丢掉。现象:视频关键帧有 40 帧,你只取前 16 帧,结尾的场景变化全部丢失,验证集上长视频几乎全错。原因:为了凑 batch,粗暴地做了“取前 N 帧”。解决:用 linspace 均匀抽样,或者用 pack_padded_sequence 处理真实长度,但后者实现复杂度高,优先推荐前面的均匀抽样方案。
第四,解冻 VGG16 后 loss 曲线像心电图。现象:微调一开始 loss 明显下降,但几个 epoch 后 loss 在下降趋势上剧烈震荡。原因:解冻的 BatchNorm 层在小 batch 下统计量抖动,同时学习率没降够。解决:解冻时把学习率降到 1e-5,batch_size 提到 16 以上,或者在解冻前固定 backbone 的 BatchNorm 参数。
6. 验证技巧:先过拟合一条视频,再谈效果优化
很多人调模型第一步就犯方向性错误,看到效果不好立刻换模型、换数据集,其实大部分问题出在训练流程没跑通。我常用的一个验证技巧是“过拟合测试”:在加入所有训练数据之前,先用一条视频把模型训练到 loss 接近 0。
这里需要你临时造一个只包含 1 个样本的数据集,序列长度用 16,标签随便指定一个类。训练 10 到 15 个 epoch,观察 loss 是否稳步下降到接近 0。如果连一条视频都过拟合不了,那模型结构或数据处理大概率有 bug,问题根本不在数据量上。这个测试成本很低,却能快速暴露梯度不更新、标签错位、维度不对等坑。
过了这个测试之后,再上全部数据训练,并跟踪验证曲线。我一般还会把几个典型场景的关键帧逐帧输出成图片,拼成一条“模型视角”的视频,直观看看模型到底在关注什么。这个习惯帮我发现过不少问题,比如某个模型根本不是在看场景内容,而是被画面左上角的台标托底了。有段时间我为了提升准确率天天调 LSTM 的层数和 hidden_size,后来才发现瓶颈在关键帧阈值和数据划分上,白白浪费了好几周。从那以后,我换模型结构或调参之前,一定会先跑一遍过拟合测试再动手。希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取