☰
VGG16+LSTM视频场景识别:关键帧提取与PyTorch实现
2026/10/1 4:15:17 网站建设 项目流程

简介:采用VGG16-LSTM进行基于关键帧的视频场景识别,是一份面向计算机、人工智能等专业的Python毕设项目。项目以视频关键帧为输入,借助VGG16提取空间特征、LSTM捕获时序依赖,可完成视频场景分类任务,适合作为毕设、课设或相关方向入门进阶的参考。压缩包共15个文件,含8个Python源码、5个pyc编译文件与2个JSON配置文件,整体仅16KB;源码按模型构建、数据加载、工具函数、训练测试等模块划分,结构清晰,便于理解与二次开发。目前已有136人学习下载。代码经测试运行成功,并配有类别索引等配置,能够帮助读者快速复现实验流程、梳理关键帧视频识别思路,也可在现有基础上扩展其他场景分类功能。

1. 用 VGG16-LSTM 做视频场景识别:这个方案解决什么问题

有一阵子帮同学调毕设,他拿单帧图片做场景识别,训练集准确率 92%,一丢到视频上就崩:镜头从教室切到走廊,模型还在输出“教室”。原因很简单,单帧没有上下文——同样是教室,画面几乎一致,“正在上课”和“下课铃响之后”语义完全相反。VGG16-LSTM 加关键帧提取,正是为这类“画面像、上下文不像”的问题准备的常规组合:VGG16 把每一帧的视觉内容压成特征向量,LSTM 把连续多个关键帧的先后关系记下来,关键帧则把视频从几百上千帧压缩成十几二十个有效样本。这个方案适合做 python 方向毕设、课设的在校生,也适合刚接触视频分类、想快速验证技术路线的入门工程师。

2. VGG16 与 LSTM 的分工:空间特征和时间秩序怎么合并

视频场景识别和图像分类的最大区别在于“多了一个时间维度”。图像分类只要回答“这一帧里有什么”,视频场景识别要回答的是“这一段时间里发生了什么”。所以最直接的做法是把问题拆成两半:空间语义交给 CNN,时间语义交给 LSTM。

2.1 VGG16 网络结构详解:为什么拿它当特征提取器

VGG16 是 13 个卷积层加 3 个全连接层的串行结构,输入通常是 224×224 的 RGB 图,通道数从 64 一路翻到 512。这个结构被 ImageNet 预训练过,所以它已经具备很强的底层视觉能力:边缘、纹理、物体部件、场景布局。在你的视频场景识别任务里,我们不关心它对 ImageNet 那 1000 类做了什么判断,只把它当作一个特征提取器,让它把一帧画面变成一组长向量。

用 PyTorch 把它拆出来非常直接:

import torch import torch.nn as nn from torchvision import models # 新版 torchvision 用 weights 参数,老版本用 pretrained=True vgg16 = models.vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1) # 只保留卷积特征部分,丢掉为 ImageNet 分类设计的全连接层 cnn = nn.Sequential(*list(vgg16.features.children())) # 用自适应平均池化把任意大小的特征图压成 1x1 avg_pool = nn.AdaptiveAvgPool2d((1, 1))

这段代码的核心是vgg16.features。熟悉 VGG16 网络结构的人一眼就能看出来,它输出的是 512 通道、7×7 大小的特征图。如果直接展开,每个时间步的输入是 512×7×7=25088 维,这个维度对 LSTM 来说太贵了。所以常见做法是接一个AdaptiveAvgPool2d((1, 1)),把 7×7 的空间信息压缩成 512 维向量。这样的好处有两个:一是输入 LSTM 的维度固定为 512,不受输入分辨率影响;二是平均池化能抑制局部噪声,对场景这种“整体语义”任务反而友好。

为什么不选 ResNet 或 MobileNet?ResNet 残差结构收敛更好,但 VGG16 的层间关系更规整,写论文、画网络结构图、手推张量尺寸都更容易。MobileNet 推理很快,可惜在小规模视频数据集上,它的特征区分度明显不如 VGG16 预训练权重来得直观。做毕设的诉求不是“刷 SOTA”,而是“每一步都能说清楚”,VGG16 是最稳妥的选择。

2.2 LSTM 神经网络的工作原理:门控记忆如何记住场景变化

LSTM 神经网络最常出现在时间序列预测这类任务里,但视频场景识别本质上也是同一个套路:输入是一串按时间排列的特征向量,输出是一个标签。LSTM 在处理第 t 个时间步时,会同时接收当前输入x_t和上一时刻的隐藏状态h_{t-1},通过遗忘门、输入门、输出门决定“忘记什么、记住什么、输出什么”。

场景识别里一个典型的例子:会议室场景的前 10 帧是投影仪黑屏,中间几帧有人推门进来坐下,最后画面转为白板上的板书。如果只看最后 3 帧,模型会误判成“板书特写”;LSTM 的门控机制会把“有人坐下”这个状态从第 15 帧一路传到第 40 帧,让最后判断同时受到“画面内容”和“事件状态”的影响。

LSTM 家族里有单向、双向、ConvLSTM,甚至还有人用双信号转换 LSTM 做回声消除这类信号处理,万变不离其宗,底子都是同一套门控机制。视频场景识别用最基本的单向 LSTM 就够了。双向 LSTM 虽然能看到前后文,但会引入“未来信息”,对实时识别不友好,而且参数量翻倍,毕设里不划算。LSTM 的时间步数量就是关键帧数量,一般在 8 到 32 之间,比语言任务小得多,所以也不用担心梯度消失。真正要担心的是后面会讲到的梯度爆炸和显存占用。

2.3 关键帧在这个组合里的位置

关键帧这里要说明白一个容易混的概念:视频剪辑软件里“跳转下一个关键帧”指的是时间轴上的标记点,和本方案里的关键帧是两回事。这里的关键帧是“能代表一段时间画面变化的帧”。先把概念掰开,后面写代码就不会被误导。

为什么要抽关键帧?一个 1080p、30fps 的视频,一分钟就是 1800 帧。直接全部送进 VGG16,普通学生电脑上光提特征就要跑十几分钟,更不要提 LSTM 对 1800 个时间步反向传播的显存消耗。而视频在场景级别高度冗余:1 秒内 24 帧画面几乎相同,真正影响场景判断的是镜头切换、物体出现、人物进出这些内容变化点。把关键帧压到 16 个左右,VGG16 的计算量直接缩小两个数量级,LSTM 也能稳定收敛。

关键帧提取做得好不好,直接决定整个方案的上限。很多毕设翻车不在模型,而在“送进去的帧根本不能代表这个场景”——比如全程抽到黑帧,或者把同一个镜头的相似帧反复送进去。下一章就用实际代码解决这个问题。

3. 关键帧提取两套做法:帧差法、镜头检测与参数权衡

关键帧是视频场景识别里最容易被糊弄过去的环节,可它恰恰是决定 VGG16-LSTM 能学到什么的第一步。这一章给两套可复现的提取方案:一套只用 OpenCV,适合快速验证;一套用 PySceneDetect,适合处理带转场、字幕、闪黑的真实视频。

3.1 用 OpenCV 做帧间直方图差异:最小可用的关键帧提取代码

最简单可靠的办法不是算像素差,而是算整帧灰度直方图的差异。直方图反映的是画面整体亮度分布,对轻微相机抖动不敏感,又能在镜头切换时产生明显跳变。

import cv2 import numpy as np def extract_keyframes(video_path, diff_thresh=0.5, step=2): cap = cv2.VideoCapture(video_path) frames = [] prev_hist = None idx = -1 while True: ret, frame = cap.read() if not ret: break idx += 1 # 隔帧采样,降低计算量 if idx % step != 0: continue gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) gray = cv2.resize(gray, (64, 64)) # 缩小后算直方图,加快速度 hist = cv2.calcHist([gray], [0], None, [64], [0, 256]) cv2.normalize(hist, hist) hist = hist.flatten() if prev_hist is None: frames.append(frame) prev_hist = hist continue # Bhattacharyya 距离:0 表示完全一致,1 表示完全不同 diff = cv2.compareHist(prev_hist, hist, cv2.HISTCMP_BHATTACHARYYA) if diff > diff_thresh: frames.append(frame) prev_hist = hist else: # 对历史直方图做平滑,避免光照渐变导致的误触发 prev_hist = 0.5 * prev_hist + 0.5 * hist cap.release() return frames

这段代码有两个关键参数。diff_thresh是触发关键帧的差异阈值,我一般从 0.5 开始调:室内固定镜头下场景切换的直方图差异通常在 0.6 以上,同场景的轻微光线变化在 0.2 以下。step是采样步长,默认 2 表示每隔一帧算一次直方图,视频帧率 30fps 时相当于每秒计算 15 次,一般够用。如果视频抖动严重,可以把 step 提到 4,代价是会漏掉持续不到 1/4 秒的短镜头。

这套方法的短板很明显:它对“亮度突变”帧极端敏感,闪光灯、字幕弹出、黑场都会导致整段连抽;反过来,画面从教室慢慢变成走廊这种镜头内渐变,直方图平滑后反而可能错过切换。所以它适合场景切换干脆的监控视频、录屏,不适合带大量转场特效的影片。

3.2 用 PySceneDetect 切镜头边界:转场和渐变更稳的方案

如果视频里有淡入淡出、闪光灯、字幕,直方图法会抽出一堆废话关键帧。这时候我一般改用 PySceneDetect 的 content-detection 模式。它的原理是计算相邻帧的像素差异并做累积,超过阈值才判定为场景边界,比单纯直方图更抗噪。

scenedetect -i input.mp4 detect-content \ --threshold 27 \ --min-scene-len 1.5 \ list-scenes --output scenes.csv

--threshold 27是 content 检测的灵敏度,数值越小越灵敏,默认就是 27。内容切换明显的视频我调到 22 让它更容易切;固定机位的教学视频我调到 35,避免把板书翻页当成场景切换。--min-scene-len 1.5表示小于 1.5 秒的镜头不单独成段,防止字幕闪烁切出一堆碎片。

命令行跑完后会生成scenes.csv,里面每一行是一个镜头的起始帧和结束帧。用 Pandas 读进来再决定每个镜头取哪一帧:

import pandas as pd # scenes.csv 开头几行是版本信息和表头,需要先看一眼再确定 skiprows scenes = pd.read_csv("scenes.csv", skiprows=3)

注意skiprows会随 scenedetect 版本变化,不要抄死,打开 CSV 文件确认表头在第几行。一般我这样用:每个镜头取中间一帧作为关键帧,因为镜头结束帧往往带有转场残留。镜头时长超过 5 秒的,从中均匀取 2 帧,避免镜头后半段信息丢失。

3.3 关键帧序列的两种组织方式:定长补齐与镜头切分

关键帧提取出来之后,下一步是组织成 LSTM 能吃的序列。这里有两种常见做法。

第一种是定长序列:不管视频长短,统一取 N 个关键帧,N 一般取 16 或 32。关键帧数量多于 N,用等间隔下采样;少于 N,用重复最后一帧补齐。

def align_frames(frames, n=16): if len(frames) >= n: indices = np.linspace(0, len(frames) - 1, n).round().astype(int) return [frames[i] for i in indices] # 帧数不足 n 时,保留全部帧,末尾重复最后一帧补足 result = frames[:] while len(result) < n: result.append(result[-1]) return result

为什么用重复最后一帧而不是补黑帧?因为 LSTM 会把黑帧当成一个“画面变黑”的事件,直接干扰门控状态。重复最后一帧至少有语义延续性,训练时不容易出现诡异的中间状态。

第二种是镜头切分:先用 PySceneDetect 把视频切成多个镜头,每个镜头抽出 1 到 2 帧,作为一个子序列分别送入网络,最后对每个镜头的预测结果做投票。这种方式保留了镜头内的时序,但对数据集组织要求更高,标签需要标注到镜头级别。毕设如果想控制复杂度,我建议直接用定长序列,模型结构最清晰。

3.4 关键帧参数怎么定:一张表说清楚调整方向

参数作用对象推荐起点调大调小
diff_thresh直方图差异阈值0.5关键帧更少,漏检转场关键帧更多,容易抽到冗余帧
step隔帧采样步长2计算更快,丢失短镜头计算更慢,短镜头更全
thresholdPySceneDetect 灵敏度27更不敏感,适合固定机位更敏感,适合频繁切换
min_scene_len最小镜头时长1.5 秒镜头更整,信息更粗镜头更碎,时序更细
seq_lenLSTM 时间步数16上下文更长,显存翻倍上下文更短,容易欠拟合

这些参数的共同调整原则是:先拿一个 5 秒的短视频试跑一遍,把抽出的关键帧逐帧保存成图片,肉眼确认抽出来的帧“能看懂这一段在讲什么”,再进入模型训练。不要一上来就在全量数据集上调参,那是在和随机性搏斗。

4. 模型搭建与训练:从视频序列到场景标签的完整 PyTorch 实现

有了关键帧序列,模型的代码量反而比数据预处理少。这一章从数据加载器写到训练循环,每一步都对应可以直接落地的代码。

4.1 数据加载器:产出“帧序列”而不是单张图

PyTorch 的 Dataset 对象要以“一个视频=一个样本”为粒度,而不是“一帧=一个样本”。数据组织成clip_paths和labels两个列表,clip_paths里每个元素是一个由关键帧图片路径组成的列表。

import torch from torch.utils.data import Dataset from torchvision import transforms import cv2 import numpy as np class SceneClipDataset(Dataset): def __init__(self, clip_paths, labels, seq_len=16, size=224): self.clip_paths = clip_paths self.labels = labels self.seq_len = seq_len self.transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) def __len__(self): return len(self.labels) def __getitem__(self, idx): paths = self.clip_paths[idx] # 定长对齐 if len(paths) >= self.seq_len: ids = np.linspace(0, len(paths) - 1, self.seq_len).round().astype(int) paths = [paths[i] for i in ids] else: paths = paths + [paths[-1]] * (self.seq_len - len(paths)) frames = [] for p in paths: img = cv2.imread(p) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (224, 224)) frames.append(self.transform(img)) return torch.stack(frames, dim=0), self.labels[idx]

返回张量的形状是(seq_len, 3, 224, 224),和普通图像分类的(3, 224, 224)不同,这一点在写DataLoader时最容易搞混。另一个需要注意的地方是训练时对序列的空间增强。常见做法是让整条序列共享同一个随机裁剪参数,否则同一段视频的不同帧会被裁剪到不同位置,等于人为引入了与时间无关的抖动。实现方式是先在循环外生成crop_params,再手动对每一帧做F.crop,需要时可以去查torchvision.transforms.functional的用法。

4.2 模型定义:VGG16 提特征、LSTM 建模、分类头输出

模型定义是 VGG16-LSTM 方案里最核心的一步,全部代码可以收进一个类。

import torch import torch.nn as nn from torchvision import models class VGG16LSTM(nn.Module): def __init__(self, n_classes, hidden_size=256, num_layers=2, dropout=0.5): super().__init__() vgg16 = models.vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1) # CNN 部分:卷积层 + 自适应平均池化,输出 (B, 512, 1, 1) self.cnn = nn.Sequential( *list(vgg16.features.children()), nn.AdaptiveAvgPool2d((1, 1)) ) self.lstm = nn.LSTM( input_size=512, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0.0 ) self.head = nn.Sequential( nn.Dropout(dropout), nn.Linear(hidden_size, n_classes) ) def forward(self, x): # x: (B, T, 3, 224, 224) B, T, C, H, W = x.shape x = x.view(B * T, C, H, W) feat = self.cnn(x) # (B*T, 512, 1, 1) feat = feat.view(B, T, -1) # (B, T, 512) out, _ = self.lstm(feat) # (B, T, hidden_size) last = out[:, -1, :] # 取最后一个时间步 return self.head(last)

vgg16.features取出卷积层,再接AdaptiveAvgPool2d,这样每帧变成 512 维特征,LSTM 的input_size直接填 512。LSTM 用batch_first=True方便前后穿插和调试,num_layers=2是毕设里性价比最高的配置——单层对复杂场景上下文建模偏弱,三层以上在几万条数据上必然过拟合。hidden_size我习惯用 256,太小记不住长程状态,太大在最后一层全连接会拖慢收敛。

一个值得展开的点:为什么取 LSTM 最后一个时间步的输出,而不是对所有时间步取平均?场景识别是一个累积判断的过程,“开会”这个结论可能要到视频最后才成立,因为你在前面看到了有人走进来、坐下、翻开笔记本。取最后一刻的输出能完整携带累积信息。全时间步平均会把早期“投影仪黑屏”这类无效信息也混进来,实际效果在多数基础数据集上都不如 last hidden state。

dropout参数只作用于隐藏层,如果num_layers=1,传入 dropout 会被 PyTorch 拒绝,所以上面加了条件判断。

4.3 训练配置:冻结 CNN 还是微调 VGG16

关键帧样本量通常只有几千到几万,VGG16 的参数量却有 1.3 亿左右,直接全量微调必过拟合。最常见做法是把 CNN 冻结,只训练 LSTM 和分类头。

for param in model.cnn.parameters(): param.requires_grad = False optimizer = torch.optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3 ) criterion = nn.CrossEntropyLoss()

冻结之后,实际参与训练的只有 LSTM 和最后的全连接层,显存占用和训练时间都大幅下降。学习率从 1e-3 起基本稳定,不必像微调预训练模型那样小心翼翼。

如果数据量接近十万级别,可以解锁 VGG16 的最后两层卷积,让底层特征适应你的数据域。此时优化器要换成 SGD 或者把 Adam 的学习率降到 1e-5 以下,否则预训练权重会被粗暴冲掉。做之前先分别跑一遍冻结和解锁的训练,对比验证集准确率,不差就别解锁,省时间也省心。

训练循环里有一个容易忽略的细节:LSTM 的梯度很容易爆炸,batch_size 加到 32 之后 loss 突然变nan,多半是梯度过大。

def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total = 0 correct = 0 loss_sum = 0.0 for frames, labels in loader: frames = frames.to(device) labels = labels.to(device) optimizer.zero_grad() logits = model(frames) loss = criterion(logits, labels) loss.backward() # 梯度裁剪,防止 LSTM 梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() pred = logits.argmax(dim=1) total += labels.size(0) correct += (pred == labels).sum().item() loss_sum += loss.item() * labels.size(0) return loss_sum / total, correct / total

clip_grad_norm_设成 1.0,能让 LSTM 在这种小数据集上稳定不少。序列长度 16、hidden_size 256、batch_size 16 是起步配置,如果显存不够,优先减 batch_size,不要减 seq_len——序列长度直接关联场景上下文,砍掉它对效果的影响比砍 batch 大得多。

5. 避坑:VGG16-LSTM 场景识别里我踩过的止血方案

这个方案的坑不在 VGG16 也不在 LSTM,而在“数据组织形式”。下面是我们复现时最容易踩的五个问题,都按“现象 → 原因 → 解决”整理。

5.1 训练时把关键帧序列里的帧打乱了

现象:训练集准确率一路冲到 90% 以上,验证集准确率却停留在四五十徘徊。把训练日志打出来看,loss 在 2 左右下不去。

原因:有人把“关键帧”当成独立图片来 shuffle,同一个视频的关键帧被随机拆到了不同 batch 里。LSTM 收到的每个样本都是乱序的帧,时间关系完全是噪声,它能做的就是“记住帧的视觉特征、忘掉顺序”,这和直接用 VGG16 单帧分类没有区别。

解决:把 shuffle 的单位限制在“视频/clip”级别。DataLoader里的shuffle=True只作用于 Dataset 的索引,只要__getitem__返回的是一整条帧序列,shuffle 的就是视频顺序,帧内部顺序不会被破坏。不要在外面手动打乱数据列表里单个帧的路径。

5.2 序列长度设得过大,显存翻车或者梯度波动

现象:把seq_len从 16 加到 64 之后,训练 loss 波动剧烈,或者直接CUDA out of memory。

原因:LSTM 反向传播的时间步数和seq_len成正比。64 个时间步意味着隐藏状态要被 rollout 64 次,梯度在每一步都可能衰减或放大;同时中间激活全部要保存在显存里,序列越长,显存占用线性上升。

解决:seq_len保持在 16 到 32 之间。关键帧提取多了就先等间隔抽样到 32 以内,而不是全量送进 LSTM。如果确实需要更长上下文,可以改成“每 K 帧取一帧”或分层抽样,不要硬堆时间步。

5.3 预训练权重下载失败,离线机器直接报 URLError

现象:models.vgg16(weights=...)在实验室机器上跑了一晚上都下不动,报URLError或者Timeout。校园网环境会限制外网,torchvision 的权重缓存就卡死了。

原因:VGG16 预训练权重需要从网络下载,torchvision 默认缓存到用户目录。离线机器根本没有下载通道。

解决:在能联网的机器上先导出权重文件,拷贝到离线机器再加载:

# 联网机器上执行一次 import torch from torchvision import models m = models.vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1) torch.save(m.state_dict(), "vgg16_imagenet.pth")

离线机器上用weights=None初始化网络后load_state_dict就行。权重文件不大,U 盘拷过去最省事,别在实验室挂着代理撸剧等下载。

5.4 关键帧里混入黑帧、字幕帧,模型预测忽高忽低

现象:同一个场景的关键帧序列,预测结果在不同类别之间反复横跳,看起来完全没有稳定性。把关键帧逐帧打印出来,发现序列里有三四帧是纯白、纯黑或者满屏字幕。

原因:帧差法对亮度突变敏感,闪光灯、弹幕、字幕条都会触发关键帧判定。这些帧的视觉信息熵极低,VGG16 提取的特征是“噪声主导”,LSTM 会被这几个异常时间步带偏。

解决:给关键帧过滤加一个信息熵检查,低于阈值就直接丢弃:

from scipy.stats import entropy import cv2 def is_valid_frame(frame, min_entropy=3.0): gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) hist = cv2.calcHist([gray], [0], None, [256], [0, 256]).flatten() hist = hist / hist.sum() return entropy(hist) > min_entropy

阈值 3.0 是经验起点,纯黑帧熵接近 0,正常室内画面灰度熵一般能到 4 以上。保存一批关键帧后把熵值从小到大排个序,视觉确认一下你的数据应该卡在哪个阈值,不要无脑套用 3.0。

5.5 类别不均衡,模型把所有样本都判成高频类

现象:验证集整体准确率有 0.7,但 F1 分数惨不忍睹,“开会”类几乎全对,“走廊”类召回率接近 0。训练日志里 “走廊”类的预测概率始终很低。

原因:CrossEntropyLoss默认对所有类别等权,数据里“开会”占 70%,模型发现把所有帧按开会输出就能把 loss 压低,懒得学其他类的区分特征。

解决:给损失函数加类别权重,让低频类样本的梯度贡献变大:

import numpy as np import torch counts = np.bincount(labels) weights = torch.tensor(1.0 / np.log1p(counts), dtype=torch.float32) criterion = nn.CrossEntropyLoss(weight=weights.to(device))

用np.log1p做平滑,避免出现某个类只有 2 条样本时权重被拉到几百倍,导致模型反过来疯狂过拟合那几个视频。权重配好之后,整体准确率可能略降,但每类 F1 会更均衡。

6. 验证与进阶:不只看准确率,还要看模型在看什么

模型训练完,不要只拿一个 accuracy 交差。下面三个方向能让结果更可信,答辩演示也更直观。

6.1 用梯度热图检查 LSTM 到底在看哪个关键帧

在模型的 forward 里把 LSTM 输出和最终 logits 拆开,取某个 batch 内的一个类别做反向传播,统计每个时间步的梯度绝对值大小,就能判断 LSTM 对哪几帧的依赖最大。

out, _ = model.lstm(feat) # feat: (B, T, 512) last = out[:, -1, :] logits = model.head(last) score = logits[0, class_id] score.backward() frame_grad = feat.grad.abs().mean(dim=2).mean(dim=0) # (T,)

对frame_grad排序,关注前 5 帧,打印出来,如果模型依赖的是“有人坐下”而不是“黑板特写”,说明 LSTM 的时间建模路径走对了。这个步骤可以一次性揪出因为关键帧抽错导致的假收敛,比看 loss 曲线直观得多。

6.2 用每类 F1 和混淆矩阵代替整体准确率

整体准确率在类别不均衡的场景下没有任何参考意义。训练结束后打印混淆矩阵,重点看哪些类别互相混淆——“走廊”和“教室门口”错得多,说明关键帧去重不够;某个类别被系统性预测成另一个,说明标签本身边界模糊。这种定性结论写进毕设结论里,比“准确率 92%”更有说服力。

6.3 在冻结模型上做二次微调,验证泛化能力

如果时间预算允许,把 VGG16 最后两层解锁,学习率设成 1e-5,在这个基础上再训 5 个 epoch。如果验证集准确率有提升,说明数据量足够支撑微调;如果掉点,说明数据量不够,老老实实保持冻结。这个实验天然是一个好的对比对照组,论文里能直接画一条曲线。

我现在的习惯是,不管模型改了多少轮,先把 10 个视频的关键帧、预测标签、每类 confidence 打在同一张图上肉眼过一遍,再决定动哪边。这个步骤花不了 15 分钟,却能避开大多数训了几天才发现数据不对的翻车。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询