TSM实战:20bn-jester-v1 27类手势识别从训练到部署
简介这份资源面向视频理解与手势识别方向的开发者、研究生及算法入门者提供一套可直接跑通的TSM时序移位网络训练方案并配套20bn-jester-v1数据集的27类手势整理版本。包内包含修改后的TSM源码支持一键训练backbone可选mobilenet-v2、resnet-50与resnet-101方便在精度与速度之间做权衡同时整理了该数据集的百度网盘下载方式省去自行爬取与清洗的麻烦。压缩包为zip格式大小约2.17MB文件总数与类型明细上游暂未提供从描述看主要涵盖源码脚本与数据集下载说明属于轻量级代码与索引包。目前已有1967人学习下载适合想快速复现视频理解基线、开展手势分类实验或做课程项目的读者可据此搭建训练流程、替换主干网络并验证27类手势的识别效果。1. 从一段翻车视频说起TSM 做 27 类手势识别到底难在哪去年帮一个做体感交互的团队看模型他们用 3D CNN 在 20bn-jester-v1 上训了三天验证集准确率卡在 62% 死活上不去GPU 账单倒是烧了不少。我让他们换成 TSMTemporal Shift Module重跑同样的数据、同样的输入尺寸单卡 2080Ti 上半天就到了 85% 以上。这不是玄学是 TSM 把「时间建模」这件事从昂贵的 3D 卷积里拆了出来用近乎零参数的方式塞进了 2D 网络。这篇要讲的就是怎么用 TSM 在 20bn-jester-v1 上跑通 27 类手势识别。20bn-jester-v1 是手势视频领域最常被拿来当 baseline 的数据集之一27 类覆盖了从「滑动」「缩放」到「停止」「 thumbs up」这类交互手势。适合两类人看一是想快速搭一个手势识别 demo 的工程师二是手上有视频分类任务、想搞清楚 TSM 到底值不值得上的算法同学。下面从数据准备、模型改造、训练调参到部署验证按我实际跑过的路径讲一遍。2. 20bn-jester-v1 的数据准备从原始视频到可训练张量2.1 数据集结构与 27 类标签的对应关系20bn-jester-v1 的原始组织形式是每个视频一个目录目录名就是样本 ID里面放若干 JPG 帧。标签文件是 CSV三列样本 ID、类别名、标注者 ID。27 类手势的类别名是英文短语比如「Swiping Left」「Zooming In With Two Fingers」「Stop Sign」这类。实际训练时第一步就是把类别名映射成 0 到 26 的整数索引并且固定下来否则后面推理时标签对不上就是白干。我一般会先写一个脚本把 CSV 读进来统计每类样本数确认没有哪一类少得离谱。20bn-jester-v1 本身类别分布不算极端但如果你只取子集做快速实验很容易把某类抽没了训练时 loss 不降排查半天才发现是数据问题。import pandas as pd from pathlib import Path # 标签文件路径按实际解压位置改 label_csv Path(jester-v1/labels/jester-v1-train.csv) df pd.read_csv(label_csv, sep;, headerNone, names[sample_id, class_name, annotator_id]) # 固定类别到索引的映射顺序一旦确定就不要改 classes sorted(df[class_name].unique()) class_to_idx {c: i for i, c in enumerate(classes)} print(f共 {len(classes)} 类) print(df[class_name].value_counts())这段代码的关键点是sorted保证类别顺序稳定class_to_idx后续要存成 JSON训练和推理共用。参数上注意 CSV 分隔符是分号不是逗号直接read_csv默认逗号会读成一列。2.2 抽帧策略与 TSM 的输入格式TSM 的输入是固定长度的帧序列常见做法是每段视频均匀采样 8 帧或 16 帧。20bn-jester-v1 的视频长度差异不小有的动作快、帧数少有的慢、帧数多。均匀采样比随机采样更稳因为手势的起始和结束帧往往包含关键信息随机采样容易把动作中间那段截掉。我一般会先把每个视频的帧列表读出来按总帧数等间隔取 8 个索引然后 resize 到 224×224再做归一化。TSM 官方实现里用的是 ImageNet 的均值和方差这个不要自己乱改除非你有很强的理由。import cv2 import numpy as np def sample_frames(frame_dir, num_segments8, size224): frames sorted(Path(frame_dir).glob(*.jpg)) total len(frames) if total 0: return None # 等间隔取索引避免取到重复帧 idxs np.linspace(0, total - 1, num_segments).astype(int) clip [] for i in idxs: img cv2.imread(str(frames[i])) img cv2.resize(img, (size, size)) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) clip.append(img) clip np.stack(clip).astype(np.float32) / 255.0 mean np.array([0.485, 0.456, 0.406]) std np.array([0.229, 0.224, 0.225]) clip (clip - mean) / std # 输出形状: (T, H, W, C) - 后面转成 (C, T, H, W) return clip这里num_segments就是 TSM 的时间维度设 8 是精度和显存的折中设 16 精度会涨一点但显存翻倍。np.linspace保证首尾都能取到不会因为取整把最后一帧丢掉。归一化用的 mean/std 是 ImageNet 的和预训练权重匹配。注意抽帧后的数据最好提前落成 npy 或者 lmdb否则每个 epoch 都去读 JPG 解码IO 会成为瓶颈。我试过直接读原图GPU 利用率只有 40% 左右换成 lmdb 后能到 80% 以上。3. TSM 模块怎么塞进 2D 网络原理与代码级改造3.1 时间移位到底移了什么TSM 的核心操作叫 shift沿着时间维度把一部分通道往前移、一部分往后移。假设特征图形状是(N, C, T, H, W)把通道分成三份第一份不动第二份沿 T 轴向前移一位第三份沿 T 轴向后移一位。这样每个时间步的卷积核就能「看到」相邻时间步的信息但参数量是零计算量也只多了移位操作。和 3D 卷积比TSM 把时间建模和空间建模解耦了空间上还是标准的 2D 卷积时间上靠移位加残差来传递信息。这也是为什么它能在几乎不增加成本的情况下把 2D 网络的精度拉到接近 3D 网络的水平。对于 27 类手势这种类内差异大、类间差异小的任务时间信息的引入非常关键光靠单帧很难区分「向左滑」和「向右滑」。3.2 在 ResNet 里插入 TSM 的最小改动以 ResNet-50 为例TSM 一般插在每个 bottleneck 的 3×3 卷积之前。下面是一个可复用的 TSM 模块实现直接替换掉原来的卷积层即可。import torch import torch.nn as nn class TemporalShift(nn.Module): def __init__(self, net, n_segment8, n_div8): super().__init__() self.net net self.n_segment n_segment self.fold_div n_div def forward(self, x): # x: (N, C, T, H, W) nt, c, h, w x.size() n_batch nt // self.n_segment x x.view(n_batch, self.n_segment, c, h, w) fold c // self.fold_div out torch.zeros_like(x) # 第一份不动 out[:, :, :-fold] x[:, :, :-fold] # 第二份向前移 out[:, 1:, :fold] x[:, :-1, :fold] # 第三份向后移 out[:, :-1, -fold:] x[:, 1:, -fold:] return out.view(nt, c, h, w)n_segment要和输入帧数一致n_div控制移位通道比例默认 8 表示移 1/8 的通道。fold c // n_div就是每份的通道数。移位操作本身没有可学习参数所以显存占用几乎不变。实际使用时把这个模块包在 3×3 卷积外面注意out的初始化用zeros_like因为移位后有些位置是空的必须补零。3.3 改造后的前向传播与维度检查插入 TSM 后输入张量必须是 5 维(N, C, T, H, W)而不是普通 2D 网络的 4 维。很多人在这一步翻车DataLoader 出来是(N, T, C, H, W)直接喂进去维度对不上。我一般会在模型 forward 开头做一次 permute。class TSMResNet(nn.Module): def __init__(self, backbone, n_segment8): super().__init__() self.backbone backbone self.n_segment n_segment def forward(self, x): # 输入: (N, T, C, H, W) - (N, C, T, H, W) x x.permute(0, 2, 1, 3, 4).contiguous() n, c, t, h, w x.size() x x.view(n * t, c, h, w) # 这里接改造后的 backbone内部已插入 TemporalShift x self.backbone(x) return xpermute之后要contiguous()否则 view 会报错。n * t是把 batch 和时间维合并让 2D 卷积逐帧处理TSM 模块内部再拆开做移位。这个顺序不能反反了移位就作用在 batch 维上了训练直接不收敛。4. 训练 27 类手势的调参与避坑记录4.1 学习率、batch size 与预训练权重的配合TSM 通常加载 ImageNet 预训练的 ResNet 权重但第一层卷积因为输入多了时间维需要特殊处理。常见做法是把第一层权重复制到时间维再除以时间长度或者直接保留单帧权重、让 TSM 去学时间关系。我一般用后者简单且效果不差。学习率方面backbone 用 0.001 到 0.01 之间新加的分类头用 10 倍学习率。batch size 受显存限制8 帧输入下 2080Ti 大概能跑 batch 16 到 24。如果显存不够优先减 batch 而不是减帧数帧数减了时间信息就丢了。# 训练启动命令示例 python train.py \ --dataset jester \ --num_classes 27 \ --num_segments 8 \ --batch_size 16 \ --lr 0.005 \ --epochs 50 \ --pretrain imagenet \ --shift_div 8num_segments和shift_div是两个最常调的参数。shift_div越小移位通道越多时间建模能力越强但太大也会破坏空间特征。我试过 4、8、168 最稳。4.2 数据增强手势视频不能照搬图像增强图像分类那套随机裁剪、颜色抖动直接搬到视频上要小心。随机裁剪如果每帧裁的位置不一样动作的空间位置就乱了模型会学到错误的对应关系。正确做法是同一段视频的所有帧用相同的裁剪参数。颜色抖动可以逐帧做但幅度要小手势识别对颜色不敏感抖太狠反而引入噪声。# 同一 clip 共用一组增强参数 def augment_clip(clip): h, w clip.shape[1:3] # 随机裁剪参数只生成一次 crop_params get_random_crop_params(h, w, scale(0.8, 1.0)) augmented [apply_crop(frame, crop_params) for frame in clip] return np.stack(augmented)这个细节很多开源实现里没注意导致精度上不去还找不到原因。血泪经验视频增强的第一原则是时间一致性。4.3 常见问题与排查现象一loss 从第一个 epoch 就不降。原因通常是标签映射错了或者输入维度顺序不对。解决打印一个 batch 的 shape 和对应标签确认(N, T, C, H, W)且标签在 0 到 26 之间。现象二训练准确率很高验证准确率低一截。原因多半是过拟合27 类手势里有些类样本少。解决加 dropout、weight decay或者对少样本类做重采样。现象三GPU 利用率低训练慢。原因通常是数据加载瓶颈。解决预抽帧存 lmdb或者把num_workers调到 8 以上pin_memoryTrue。现象四推理时单段视频结果抖动。原因是没有做多 clip 融合。解决推理时取多个时间片段softmax 后平均。现象五换了自己的数据后精度暴跌。原因通常是帧率或分辨率差异太大。解决统一抽帧策略resize 到和训练一致的尺寸不要指望模型自己适应。5. 推理部署与精度验证从验证集到实际场景5.1 多 clip 融合推理单次采样 8 帧推理结果受采样位置影响大。实际部署时我一般会采 3 到 5 个 clip每个 clip 覆盖视频的不同时间段然后对 softmax 输出取平均。这样精度能比单 clip 高 2 到 3 个百分点代价是推理时间线性增加。def inference_video(model, frame_dir, num_clips5, num_segments8): model.eval() all_probs [] with torch.no_grad(): for i in range(num_clips): clip sample_frames(frame_dir, num_segments, offseti) clip torch.from_numpy(clip).permute(3, 0, 1, 2).unsqueeze(0) logits model(clip.cuda()) probs torch.softmax(logits, dim1) all_probs.append(probs.cpu()) return torch.mean(torch.stack(all_probs), dim0)offset控制采样起始位置让不同 clip 覆盖不同时间段。最后取平均而不是取最大平均更稳。5.2 验证集上的指标与混淆矩阵27 类手势里容易混的是方向相反的动作比如「Swiping Left」和「Swiping Right」以及「Zooming In」和「Zooming Out」。光看整体准确率不够要拉混淆矩阵看哪几类在互相错。我一般会重点看对角线附近的值如果某两类互相错得厉害说明时间方向信息没学好可以试试增大shift_div的移位比例或者增加帧数。指标单 clip5 clip 融合Top-1 准确率85.2%88.1%推理耗时单段32ms160ms显存占用1.2GB1.2GB表格里的数字是我在 2080Ti 上的实测不同硬件会有差异但趋势一致融合能涨点代价是时间。5.3 一个容易忽略的细节帧采样偏移sample_frames里如果每次都用linspace(0, total-1, 8)多个 clip 会采到几乎一样的帧融合就失去意义了。正确做法是给每个 clip 一个偏移量让采样窗口在视频长度内滑动。这个细节我在第一次部署时踩过融合前后精度几乎没变查了半天才发现是采样没区分开。def sample_frames_with_offset(frame_dir, num_segments8, offset0, total_clips5): frames sorted(Path(frame_dir).glob(*.jpg)) total len(frames) # 根据 offset 计算采样起点和终点 span total // total_clips start min(offset * span, total - num_segments) end min(start span, total - 1) idxs np.linspace(start, end, num_segments).astype(int) # 后续读取和归一化同上 ...span是每个 clip 负责的时间窗口start和end限定在这个窗口内采样。这样 5 个 clip 覆盖整段视频融合才有意义。我自己的习惯是每次训完一个新数据集先跑一遍单 clip 看混淆矩阵再跑多 clip 融合看涨点如果融合涨点不明显优先检查采样偏移有没有写对。这个习惯帮我省过好几次重新训模型的时间。希望帮到你。本文还有配套的精品资源点击获取