ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TSM视频理解实战:20bn-jester-v1数据集27类手势识别

TSM视频理解实战:20bn-jester-v1数据集27类手势识别 简介这份资源面向视频理解与手势识别方向的学习者和开发者提供基于TSM时序移位模块的完整训练方案并配套20bn-jester-v1数据集的27类手势整理版本。资源包内包含修改后的TSM源码支持一键训练backbone可选mobilenet-v2、resnet-50与resnet-101方便在不同算力条件下做精度与速度的权衡实验。压缩包为zip格式大小约2.17MB文件总数与类型明细上游暂未提供但核心内容以源码与数据集下载整理为主可直接用于复现实验或二次开发。目前已有1967人学习下载说明该方案在中文社区具备一定参考价值。读者可借此快速搭建手势识别训练流程理解TSM在视频时序建模中的作用并基于27类手势数据完成从数据准备到模型训练、验证的完整闭环适合课程设计、毕业项目或算法入门实践。1. 从一段翻车视频说起TSM 做 27 类手势识别到底难在哪去年帮一个做体感交互的团队看模型他们用 3D 卷积在自采数据上训了三天准确率卡在 60% 上不去GPU 账单倒是先爆了。换成 TSMTemporal Shift Module之后同样的数据、同样的 backbone单卡半天就跑到了 85% 以上。这不是玄学是 TSM 把「时间建模」这件事从昂贵的 3D 卷积里拆了出来用几乎零参数的方式塞进了 2D 网络。这篇要聊的就是基于视频理解 TSM 和 20bn-jester-v1 数据集做 27 类手势识别的完整落地路径。20bn-jester-v1 是一个大规模手势视频数据集27 个类别覆盖了从「滑动」「停止」到「推」「拉」这类交互动作非常适合做手势交互的原型验证。TSM 则是目前轻量视频理解里性价比最高的方案之一它让你不用 3D 卷积也能拿到时序信息。适合谁看手里有视频分类需求、想快速跑通一个可复现 baseline 的工程师被 3D 卷积显存和速度折磨过、想换轻量方案的人以及想拿手势识别做交互 Demo 但不知道从哪下手的新手。下面从原理选型一路讲到训练排错中间给可直接抄的代码和参数。2. TSM 为什么能用 2D 卷积做时序建模原理与选型理由2.1 时序位移的核心思想把相邻帧的信息「搬」到当前帧视频和图像的本质区别在于多了时间维度。传统 2D 卷积对每一帧独立处理丢掉了帧间运动信息3D 卷积虽然能建模时间但参数量和计算量直接翻几倍显存吃不消。TSM 的思路很取巧既然 2D 卷积的通道维度是现成的那就把相邻帧的部分通道「移位」到当前帧让 2D 卷积在感受野里自然看到前后帧的信息。具体来说TSM 在残差块的每个卷积层前做一次 shift 操作。假设特征图有 C 个通道把其中 1/8 的通道向前移一帧1/8 向后移一帧剩下的 3/4 保持不动。这样当前帧的卷积核就能同时看到 t-1、t、t1 三个时刻的特征时序建模能力就嵌进去了。关键是这个操作没有额外参数计算量几乎可以忽略。提示shift 的比例不是随便定的。1/8 是原论文在 Kinetics 上验证过的经验值太小时序信息不足太大空间特征被破坏。手势识别这种动作幅度中等、帧间变化不算剧烈的任务1/8 到 1/4 之间都可以试。2.2 为什么手势识别选 TSM 而不是 I3D 或 SlowFast手势识别有几个特点动作持续时间短、背景相对固定、类别之间的差异集中在手部轨迹上。I3D 这类 3D 卷积对长时序建模强但参数量大在小规模手势数据上容易过拟合SlowFast 双路径设计更适合复杂场景但对单卡训练不友好。TSM 的优势在于backbone 可以用 ResNet-50 这种成熟 2D 结构预训练权重直接复用训练成本低推理速度快。在 20bn-jester-v1 上27 类手势的类间差异其实不算特别细粒度TSM 的时序位移足以捕捉「手从哪移到哪」这个核心信息。实测下来ResNet-50 TSM 在单张 24G 卡上 batch size 能开到 32 以上训练 50 个 epoch 大概 6 到 8 小时准确率能到 90% 左右。这个性价比是 3D 卷积方案很难比的。2.3 最小可跑通的 TSM 模块实现下面是一个 TSM 模块的核心代码直接嵌到 ResNet 的 Bottleneck 里就能用。注意 shift 操作要在卷积之前做且只对部分通道操作。import torch import torch.nn as nn class TemporalShift(nn.Module): def __init__(self, net, n_segment8, n_div8): super(TemporalShift, self).__init__() self.net net self.n_segment n_segment # 一个视频切成的片段数 self.fold_div n_div # 参与移位的通道比例分母 def forward(self, x): # x shape: [N*T, C, H, W]N 是 batchT 是片段数 x self.shift(x, self.n_segment, fold_divself.fold_div) return self.net(x) staticmethod def shift(x, n_segment, fold_div8): nt, c, h, w x.size() n_batch nt // n_segment x x.view(n_batch, n_segment, c, h, w) fold c // fold_div # 前 fold 个通道向前移一帧中间 fold 个通道向后移一帧 out torch.zeros_like(x) out[:, :-1, :fold] x[:, 1:, :fold] # 向前移位 out[:, 1:, fold:2*fold] x[:, :-1, fold:2*fold] # 向后移位 out[:, :, 2*fold:] x[:, :, 2*fold:] # 剩余通道不变 return out.view(nt, c, h, w)逻辑说明输入张量按n_segment切回[N, T, C, H, W]然后对通道分三段处理。前fold个通道取下一帧的值向前移中间fold个通道取上一帧的值向后移剩余通道保持原样。这样每个时刻的特征都混入了相邻帧信息。参数n_segment要和 dataloader 里一个视频采样的帧数一致n_div控制移位通道比例默认 8 对应 1/8。2.4 把 TSM 嵌进 ResNet Bottleneck 的改造点TSM 不是独立网络是要嵌到现有 2D backbone 里的。以 ResNet 的 Bottleneck 为例在第一个 1x1 卷积之前插入 TemporalShift 即可。改造时注意两点一是 shift 只做一次不要每个卷积都加二是 downsample 分支不需要 shift保持恒等映射。from torchvision.models.resnet import Bottleneck class TSNBottleneck(Bottleneck): def __init__(self, inplanes, planes, stride1, downsampleNone, n_segment8, n_div8): super(TSNBottleneck, self).__init__(inplanes, planes, stride, downsample) # 在第一个卷积前插入时序移位 self.conv1 nn.Sequential( TemporalShift(self.conv1, n_segment, n_div), ) def forward(self, x): # 其余 forward 逻辑与标准 Bottleneck 一致 identity x out self.conv1(x) out self.bn1(out) out self.relu(out) out self.conv2(out) out self.bn2(out) out self.relu(out) out self.conv3(out) out self.bn3(out) if self.downsample is not None: identity self.downsample(x) out identity out self.relu(out) return out这里把conv1包了一层TemporalShift注意TemporalShift内部先 shift 再调用原conv1所以传入的self.conv1是原始卷积层。n_segment要和数据加载时每个视频采样的帧数对齐比如你每个视频采 8 帧这里就填 8。如果显存紧张可以把n_div调大比如 16减少移位通道数。3. 20bn-jester-v1 数据准备从原始视频到可训练张量3.1 数据集结构与 27 类标签映射20bn-jester-v1 的原始结构是每个视频一个目录目录里是连续编号的 JPG 帧外加一个 CSV 标注文件。标注文件三列视频 ID、类别标签、帧数。27 个类别包括「No gesture」「Thumb up」「Stop sign」「Swiping」等。实际训练时通常把「No gesture」也当作一类或者直接过滤掉看你的业务需求。常见做法是先解析 CSV构建video_id - label的映射然后按类别分层划分训练集和验证集。手势数据有个坑不同类别的样本数差异可能很大划分时要保证每个类别在验证集里都有足够样本否则验证准确率波动会很大。import pandas as pd from sklearn.model_selection import train_test_split def load_annotations(csv_path): # 假设 CSV 无表头列依次为 video_id, label, frame_count df pd.read_csv(csv_path, sep;, headerNone, names[video_id, label, frames]) # 构建标签到索引的映射 classes sorted(df[label].unique()) label2idx {c: i for i, c in enumerate(classes)} df[label_idx] df[label].map(label2idx) return df, label2idx def split_dataset(df, val_ratio0.1): # 按类别分层划分保证每类都有验证样本 train_df, val_df train_test_split( df, test_sizeval_ratio, stratifydf[label_idx], random_state42) return train_df.reset_index(dropTrue), val_df.reset_index(dropTrue)逻辑说明load_annotations读取 CSV 并生成类别索引映射注意分隔符可能是;或\t要按实际文件调整。split_dataset用stratify做分层抽样避免某个类别在验证集里缺失。val_ratio一般 0.1 到 0.2手势类别少的时候可以取 0.2 让验证更稳。3.2 视频帧采样策略均匀采样还是密集采样TSM 需要固定长度的输入片段常见做法是从每个视频里均匀采样n_segment帧。比如一个视频有 30 帧n_segment8那就每隔约 4 帧取一帧。均匀采样的好处是能覆盖整个动作过程不会只看到局部。另一种是密集采样取连续 8 帧适合动作变化快的场景但手势识别里均匀采样通常更稳。采样时要注意帧数不足的情况。如果视频总帧数小于n_segment要么循环补帧要么直接跳过这个样本。我一般会设一个最小帧数阈值比如 8 帧低于这个值的样本直接丢弃避免训练时出现空张量。import os import cv2 import numpy as np def sample_frames(video_dir, n_segment8, min_frames8): frames sorted(os.listdir(video_dir)) if len(frames) min_frames: return None # 均匀采样索引 indices np.linspace(0, len(frames) - 1, n_segment).astype(int) sampled [] for idx in indices: img_path os.path.join(video_dir, frames[idx]) img cv2.imread(img_path) img cv2.resize(img, (224, 224)) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) sampled.append(img) # 归一化并转成 [T, H, W, C] - [T, C, H, W] arr np.stack(sampled).astype(np.float32) / 255.0 mean np.array([0.485, 0.456, 0.406]) std np.array([0.229, 0.224, 0.225]) arr (arr - mean) / std return arr.transpose(0, 3, 1, 2)逻辑说明np.linspace生成均匀分布的索引保证覆盖整个视频。min_frames过滤掉过短视频。归一化用 ImageNet 的均值和标准差因为 backbone 是预训练的 ResNet。返回形状是[T, C, H, W]后续在 dataloader 里再拼成[N*T, C, H, W]喂给 TSM。3.3 构建 PyTorch Dataset 与 DataLoaderDataset 的核心是把采样后的帧张量按 TSM 的要求组织好。TSM 期望输入是[N*T, C, H, W]所以 collate 的时候要把 batch 和 time 维度合并。下面是一个可直接用的 Dataset 和 collate 函数。from torch.utils.data import Dataset, DataLoader class JesterDataset(Dataset): def __init__(self, df, root_dir, n_segment8): self.df df self.root_dir root_dir self.n_segment n_segment def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] video_dir os.path.join(self.root_dir, str(row[video_id])) frames sample_frames(video_dir, self.n_segment) if frames is None: # 兜底返回全零张量训练时可通过 loss mask 忽略 frames np.zeros((self.n_segment, 3, 224, 224), dtypenp.float32) label row[label_idx] return torch.tensor(frames), torch.tensor(label) def collate_fn(batch): # batch 是 [(T,C,H,W), label] 列表 videos torch.stack([b[0] for b in batch]) # [N, T, C, H, W] labels torch.tensor([b[1] for b in batch]) # 合并 N 和 T 维度适配 TSM 输入 n, t, c, h, w videos.size() videos videos.view(n * t, c, h, w) return videos, labels逻辑说明__getitem__返回单视频的[T, C, H, W]张量和标签。collate_fn先把 batch 内视频堆成[N, T, C, H, W]再 view 成[N*T, C, H, W]这样 TSM 模块内部就能按n_segment切回时间维度。注意n_segment要和模型里的设置一致否则 shift 会错位。num_workers建议设 8 以上因为读 JPG 帧是 IO 瓶颈。4. 训练配置与调参让 27 类手势识别跑到 90% 以上4.1 学习率、优化器与 warmup 策略TSM 训练和普通 2D 分类有个区别时序移位会引入一定的梯度噪声学习率不能太大。我一般用 SGD初始学习率 0.01momentum 0.9weight decay 1e-4。前 5 个 epoch 做 warmup学习率从 0.001 线性升到 0.01之后按 cosine 衰减。这样比直接上 0.01 稳定很多尤其是 batch size 较大的时候。如果显存够batch size 开到 32 或 48学习率可以相应放大到 0.02。但手势数据类别少batch 太大反而容易过拟合32 是个比较稳的起点。验证集准确率连续 5 个 epoch 不涨就降学习率降 10 倍。import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR, LambdaLR def build_optimizer(model, base_lr0.01, warmup_epochs5, total_epochs50): optimizer optim.SGD(model.parameters(), lrbase_lr, momentum0.9, weight_decay1e-4) # warmup cosine 组合调度 def lr_lambda(epoch): if epoch warmup_epochs: return (epoch 1) / warmup_epochs progress (epoch - warmup_epochs) / (total_epochs - warmup_epochs) return 0.5 * (1 np.cos(np.pi * progress)) scheduler LambdaLR(optimizer, lr_lambda) return optimizer, scheduler逻辑说明lr_lambda在 warmup 阶段线性升到 base_lr之后按 cosine 从 base_lr 降到 0。warmup_epochs一般取总 epoch 的 10% 左右50 个 epoch 就取 5。weight_decay用 1e-4 是视频分类的常见值太大欠拟合太小过拟合。4.2 数据增强手势识别里哪些增强真正有用视频分类的数据增强比图像麻烦因为要保证时间一致性。常用的有随机裁剪从 256x256 裁到 224x224、随机水平翻转、颜色抖动。注意水平翻转要谨慎因为有些手势左右手含义不同比如「左滑」和「右滑」翻转后就反了。如果类别里有方向性手势翻转增强要禁用或者只对部分类别用。时间维度上可以做随机采样偏移比如均匀采样时加一点随机抖动让每次 epoch 看到的帧不完全一样。这个增强对防止过拟合很有效实现也简单在np.linspace的索引上加个小随机量再 clip 就行。import random def sample_frames_with_jitter(video_dir, n_segment8, jitter2): frames sorted(os.listdir(video_dir)) if len(frames) n_segment: return None base_indices np.linspace(0, len(frames) - 1, n_segment) # 每个采样点加随机抖动 jittered base_indices np.random.uniform(-jitter, jitter, n_segment) indices np.clip(jittered, 0, len(frames) - 1).astype(int) # 后续读取和归一化同上 ...逻辑说明jitter控制抖动幅度一般取 1 到 3 帧。太大可能跳过关键动作帧太小起不到增强效果。这个增强只在训练时用验证和测试时用纯均匀采样。4.3 训练循环与验证指标监控训练循环里要注意 TSM 的输入形状。模型 forward 接收[N*T, C, H, W]输出[N*T, num_classes]需要 reshape 回[N, T, num_classes]再对时间维度做平均池化得到[N, num_classes]的最终 logits。这是 TSM 推理的标准做法叫「segment consensus」。def train_one_epoch(model, loader, optimizer, criterion, device, n_segment): model.train() total_loss, correct, total 0, 0, 0 for videos, labels in loader: videos, labels videos.to(device), labels.to(device) optimizer.zero_grad() outputs model(videos) # [N*T, num_classes] # reshape 回 [N, T, num_classes] 并平均 n labels.size(0) outputs outputs.view(n, n_segment, -1).mean(dim1) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * n correct (outputs.argmax(1) labels).sum().item() total n return total_loss / total, correct / total逻辑说明outputs.view(n, n_segment, -1)把[N*T, C]还原成[N, T, C]mean(dim1)对时间维平均。这是 TSM 的标准推理方式训练时也这么做能保持一致性。criterion用 CrossEntropyLoss如果类别不均衡可以加 class weight。4.4 关键参数速查表参数推荐值说明n_segment8每个视频采样帧数和 TSM 模块一致n_div8移位通道比例分母1/8 通道参与移位batch_size32单卡 24G 显存下的稳妥值base_lr0.01SGD 初始学习率warmup_epochs5总 epoch 的 10% 左右weight_decay1e-4视频分类常用值input_size224和 ImageNet 预训练一致epochs50手势数据通常 40 到 60 收敛5. 避坑与排查TSM 手势识别里最容易翻车的 5 个点5.1 准确率不涨loss 震荡先查 n_segment 是否对齐现象训练 loss 一直在 2.0 附近震荡准确率随机水平。原因模型里的n_segment和 dataloader 里的采样帧数不一致导致 shift 操作把不同视频的帧混在一起了。解决打印输入张量的 shape确认N*T里的 T 和模型n_segment相等。这个坑我踩过两次血泪经验是在模型 forward 开头加一句 assert。5.2 验证集准确率远低于训练集检查数据划分是否泄漏现象训练准确率 95%验证只有 70%。原因同一个视频的帧被分到了训练和验证集或者划分时没按视频 ID 去重。解决划分必须以视频为单位不能按帧划分。用train_test_split时传入的是视频级 DataFrame不是帧级。另外检查 CSV 里有没有重复的 video_id。5.3 显存溢出降低 n_segment 或开启混合精度现象batch size 开到 16 就 OOM。原因TSM 输入是N*T帧实际显存占用是普通 2D 网络的 T 倍。解决优先用混合精度训练AMP能省 30% 到 40% 显存其次降低n_segment到 6 或 4但别低于 4否则时序信息不够。还可以用梯度累积模拟大 batch。5.4 推理速度慢检查是否误用了 3D 卷积现象单视频推理要几百毫秒。原因backbone 里混入了 3D 卷积层或者 TSM 模块被重复插入。解决确认所有卷积都是 2D 的TSM 只在每个 Bottleneck 的第一个卷积前插一次。用torchsummary或直接打印模型结构检查。5.5 某些类别识别率极低看是不是方向性手势被翻转增强搞反了现象「左滑」和「右滑」总是混淆。原因水平翻转增强把方向反过来了模型学混了。解决对方向性类别禁用水平翻转或者把翻转后的标签也相应交换。更稳妥的做法是训练时不做水平翻转改用随机裁剪和颜色抖动。6. 进阶技巧用 TSM 做在线手势识别的滑动窗口推理训练完模型只是第一步真正落地到交互场景需要做在线推理。手势识别和普通视频分类的区别在于用户是实时做动作的你不能等整个视频结束再分类。常见做法是滑动窗口维护一个帧缓冲区每隔几帧取一个窗口做推理输出平滑后的类别。具体实现上维护一个长度为n_segment * stride的队列每次新来一帧就入队队满后按stride间隔采样n_segment帧送模型。输出用指数移动平均平滑避免类别跳变。stride控制推理频率太小浪费算力太大响应延迟。手势交互一般stride取 2 到 4 帧。from collections import deque class OnlineGestureRecognizer: def __init__(self, model, n_segment8, stride3, smooth0.7): self.model model self.n_segment n_segment self.stride stride self.smooth smooth self.buffer deque(maxlenn_segment * stride) self.probs None def update(self, frame): self.buffer.append(frame) if len(self.buffer) self.n_segment * self.stride: return None # 按 stride 间隔采样 indices [i * self.stride for i in range(self.n_segment)] frames [self.buffer[i] for i in indices] tensor torch.stack([torch.tensor(f) for f in frames]) tensor tensor.view(1 * self.n_segment, 3, 224, 224).cuda() with torch.no_grad(): out self.model(tensor) out out.view(1, self.n_segment, -1).mean(dim1) prob torch.softmax(out, dim1).cpu().numpy()[0] # 指数移动平均平滑 if self.probs is None: self.probs prob else: self.probs self.smooth * self.probs (1 - self.smooth) * prob return self.probs.argmax()逻辑说明buffer用deque维护固定长度帧队列maxlen设为n_segment * stride保证采样索引不越界。update每次入队一帧队满后按stride间隔取n_segment帧推理。smooth控制平滑系数越大越稳但响应越慢0.7 是个折中值。实际部署时可以把模型转成 ONNX 或 TensorRT推理延迟能压到 10ms 以内。验证在线推理效果时我一般会录几段连续手势视频模拟实时输入看输出类别是否跟得上动作变化。如果发现延迟明显优先调小stride或n_segment而不是换模型。手势交互对延迟的容忍度比准确率低200ms 以内的响应才算可用。最后说个习惯每次改完模型结构或数据管道先拿 10 个样本过一遍 forward打印每层输出 shape确认没有维度错位再开训。这个检查花不了两分钟但能省下几个小时的无效训练。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进