简介:这是一份面向具备Python基础、希望入门或复现视频行为识别的深度学习实践项目,聚焦从连续视频帧中自动识别人体动作,可用于智能监控、体育分析与人机交互等场景的学习与实验。压缩包共40个文件,以38个Python脚本为主,辅以1个说明文档和1个运行脚本,整体约41KB,体量轻便,便于阅读源码与二次修改。已有2441人学习下载,具备一定参考热度。项目围绕视频数据预处理、时空特征建模、模型训练与评估展开,涵盖卷积网络结合循环网络、3D卷积、注意力机制等思路,并提供配置、数据加载、变换与日志等模块,可帮助读者理解行为识别从数据处理到推理部署的关键流程,也适合在此基础上调整网络结构、替换数据集或尝试迁移学习,以提升识别准确率与泛化能力。
1. 一段 30 秒的监控,比一万张图片更考验人
凌晨两点,值班的同事甩过来一段 30 秒的走廊视频,问能不能自动标出“有人反复徘徊”的时段。拿单帧图像分类模型去套,结果基本没法看——画面里每一帧都只是“一个人站着”,真正有用的信息全藏在帧与帧之间的时序变化里。深度学习视频中的行为识别要解决的就是这件事:输入一段连续帧,输出动作类别,或者更进一步给出动作的起止区间。这份资源围绕一条可复现的完整链路组织:视频解码与片段采样、3D 卷积网络训练、滑窗推理与投票、误报抑制,配套脚本、配置和小规模标注样例,跑通之后换成自己的数据即可。它适合已经能用 PyTorch 训练图像分类、想把模型推进到视频域的开发者,也适合被“验证集准确率虚高”折腾过、想搞清楚时序数据该怎么划分的从业者。
2. 数据管线:把一段视频拆成可训练的片段样本
行为识别项目里,八成翻车都发生在数据这一步,模型本身反而很少是根因。视频不是图片,它多了一个时间轴,采样方式、片段长度、划分逻辑任何一处没想清楚,训练曲线都会给出一个好看但虚假的结果。这一章先把数据管线拆开讲透。
2.1 采样策略:均匀采样、密集采样与它们各自的边界
模型吃进去的张量形状是T×C×H×W,其中T是帧数,常见取值 8、16、32。采样要回答的核心问题是:这T帧从原始视频的哪个时间范围里取、间隔多大。这里有个容易被忽略的换算关系——片段覆盖的真实时长约等于T / fps × 采样步长。以 25fps 的视频、取 16 帧、步长 4 为例,覆盖时长是16 / 25 × 4 ≈ 2.56 秒。步长为 1 时只覆盖 0.64 秒,多数动作在这个尺度上还没做完,模型看到的是“动作的某个瞬间”,自然学不到时序模式。
三种采样方式各有适用面。均匀采样把整段视频等分成T份各取一帧,跨度大、成本低,适合长视频粗分类;密集采样在连续帧上取T帧,时序细节最完整,但对长动作会截断;分段采样(把视频切T段、每段随机取一帧)兼顾跨度和随机性,是训练阶段最常用的做法。我的默认配置是:训练用分段采样加随机起点,推理用密集采样加固定步长,两边覆盖时长尽量对齐。
| 采样方式 | 覆盖时长 | 时序细节 | 典型用途 |
|---|---|---|---|
| 均匀采样 | 整段视频 | 弱 | 长视频粗分类、快速预览 |
| 密集采样 | 0.5~1 秒 | 强 | 短动作、精细分类 |
| 分段采样 | 可配置,2~5 秒 | 中 | 训练阶段主流选择 |
提示:先量一下你的数据里动作平均持续多少秒,再倒推
T和步长。这一步花十分钟,比后面调三天学习率管用。
2.2 用 PyAV 解码并落盘片段缓存
视频解码是整条链路里最容易被低估的开销。直接在DataLoader里现场解码,GPU 利用率常常掉到 20% 以下,瓶颈全在 CPU 上。稳妥做法是先离线解码一遍,把采样好的片段以uint8的 npy 形式落盘,训练时只做读取和归一化。体积上,一个16×112×112×3的片段约 600KB,一万个片段大概 6GB,换来的是训练吞吐翻几倍。
import av import numpy as np import os def sample_clip(path, num_frames=16, max_seconds=8.0): """从视频前 max_seconds 秒内均匀取 num_frames 帧,返回 (T,H,W,3) uint8""" container = av.open(path) stream = container.streams.video[0] fps = float(stream.average_rate) if stream.average_rate else 25.0 limit = max(num_frames, int(fps * max_seconds)) # 解码上限,防止长视频吃满内存 buf = [] for i, frame in enumerate(container.decode(stream)): if i >= limit: break buf.append(frame.to_ndarray(format="rgb24")) # 统一转 RGB,避开 BGR 陷阱 container.close() if not buf: raise ValueError(f"decode failed: {path}") n = len(buf) idx = np.linspace(0, n - 1, num_frames) idx = np.clip(idx.round().astype(int), 0, n - 1) # 帧数不足时允许重复帧兜底 clip = np.stack([buf[i] for i in idx], axis=0) return clip def build_cache(video_list, out_dir, num_frames=16): os.makedirs(out_dir, exist_ok=True) for vp in video_list: name = os.path.splitext(os.path.basename(vp))[0] out = os.path.join(out_dir, name + ".npy") if os.path.exists(out): continue # 断点续跑,长任务必备 clip = sample_clip(vp, num_frames=num_frames) np.save(out, clip)sample_clip里limit控制解码上限,防止误传一个十小时的文件把内存打爆;to_ndarray(format="rgb24")显式指定色彩顺序,很多解码器默认吐 BGR,不转的话训练集和推理端颜色通道对不上,线下线上指标能差十几个点。np.linspace保证帧索引在时间轴上的分布是均匀的,np.clip处理短视频帧数不足num_frames的情况,重复末帧比直接报错更实用。build_cache的os.path.exists判断是断点续跑用的,解码几万个视频时中途崩一次不至于从头再来。
真实数据上还有两个细节值得处理:一是旋转元数据,手机竖拍的视频容器里带 rotation 标记,PyAV 不一定自动应用,需要在解码后按stream.side_data判断;二是可变帧率,average_rate只是平均值,对 VFR 视频最好用frame.pts做时间戳对齐,否则采到的帧在时间上并不均匀。
2.3 按视频 ID 划分数据集,而不是按片段
这是行为识别里最容易埋雷的一步。同一条视频切出来的相邻片段高度相似,如果随机按片段划分,训练集和验证集里会同时出现“同一个人同一个动作的不同帧段”,模型只要记住背景和人物衣着就能拿高分,验证集准确率冲到 95%,换一批数据立刻掉到 40%。正确做法是按源视频 ID 做划分,同一视频的所有片段必须落在同一个集合里。
import hashlib def split_of(video_id, salt="v1", ratios=(0.7, 0.15, 0.15)): """用哈希做确定性划分:同一 video_id 永远落在同一集合,且不需要保存随机状态""" h = int(hashlib.md5((salt + video_id).encode("utf-8")).hexdigest()[:8], 16) r = h / 0xFFFFFFFF if r < ratios[0]: return "train" if r < ratios[0] + ratios[1]: return "val" return "test"用哈希而不是random.shuffle,好处是划分结果可复现,换机器、重跑脚本、增删部分数据后,已有视频的归属不会变,实验对比才有意义。salt参数用来换一版划分做交叉验证,改一下字符串就行。注意划分比例的粒度受视频总数限制,几百条视频的话 test 集只有几十条,指标波动会很大,这时候更该看混淆矩阵而不是单一准确率。
类别索引也要固化下来,写进一个labels.json,训练、推理、评估全部读同一份。中途往数据集里加新类别时,追加到末尾而不是插到中间,否则旧模型的输出维度全错位,这种问题在联调时非常难查。
3. 模型与训练:2D 骨干、3D 卷积和时空注意力的取舍
数据管线跑通之后,选型决定了后面所有事情的上限。视频理解有三条主流路线,它们的差别不只是精度,还包括显存、训练时长和部署难度,选之前得把账算清楚。
3.1 三条技术路线的显存与精度权衡
2D 骨干加时序聚合(TSN、TSM 这一类)把每帧当独立图片过 CNN,再在特征层做时序融合。优点是能直接复用 ImageNet 预训练权重,显存占用和图像分类几乎一样,部署也简单;缺点是对快速动作和精细时序关系不敏感,TSM 通过位移部分通道引入时序,算是折中方案。
3D 卷积直接在T×H×W上做卷积,时序建模能力最强,R(2+1)D 把 3D 卷积拆成空间 2D 加时间 1D,参数量和优化难度都比原始 3D 卷积友好。代价是显存:同样输入尺寸下,一个 16 帧片段过一次 3D 骨干的激活值大概是单帧的十几倍。时空注意力(TimeSformer 一类)在长视频上表现好,但显存和训练成本最高,小数据集上极易过拟合。
| 路线 | 代表结构 | 16 帧 112² 单样本显存 | 预训练权重 | 部署复杂度 |
|---|---|---|---|---|
| 2D + 时序聚合 | TSN / TSM | 约 1.5GB | 丰富 | 低 |
| 3D 卷积 | R(2+1)D-18 | 约 3~4GB | 可用 | 中 |
| 时空注意力 | 视频 Transformer | 6GB 以上 | 较少 | 高 |
小数据集(几千个片段以内)我的建议是先跑 R(2+1)D-18,它在这个规模上性价比最好,显存也还能在单张消费级卡上跑起来。数据量上万之后再考虑更重的结构。
3.2 用 R(2+1)D-18 跑通一个训练循环
模型构建只需要替换最后的分类头。torchvision 里有现成的结构和 Kinetics 预训练权重,比自己从头搭省事,也能借到一部分动作先验。
import torch import torch.nn as nn from torchvision.models.video import r2plus1d_18, R2Plus1D_18_Weights def build_model(num_classes=10, pretrained=True, dropout=0.2): weights = R2Plus1D_18_Weights.KINETICS400_V1 if pretrained else None model = r2plus1d_18(weights=weights) in_features = model.fc.in_features # 分类头加 dropout,小数据集上能明显压住过拟合 model.fc = nn.Sequential(nn.Dropout(dropout), nn.Linear(in_features, num_classes)) return model数据集类负责把缓存好的 npy 转成模型要的张量,归一化用的均值方差沿用 Kinetics 统计值,只要用了预训练权重就必须保持一致,否则预训练特征全部失效。
import numpy as np import torch import torch.nn.functional as F MEAN = torch.tensor([0.43216, 0.394666, 0.37645]).view(3, 1, 1, 1) STD = torch.tensor([0.22803, 0.22145, 0.216989]).view(3, 1, 1, 1) class ClipDataset(torch.utils.data.Dataset): def __init__(self, items, clip_len=16, size=112, train=True): self.items = items # [(npy_path, label), ...] self.clip_len = clip_len self.size = size self.train = train def __len__(self): return len(self.items) def __getitem__(self, i): path, label = self.items[i] arr = np.load(path) # (T,H,W,3) uint8 arr = arr[: self.clip_len] # 统一截断到 clip_len x = torch.from_numpy(arr).permute(3, 0, 1, 2).float().div_(255.0) x = F.interpolate(x.unsqueeze(0), size=(self.size, self.size), mode="bilinear", align_corners=False, antialias=True).squeeze(0) x = (x - MEAN) / STD return x, labelpermute(3, 0, 1, 2)把T,H,W,C转成C,T,H,W,这是 torchvision 视频模型的约定顺序,弄反了不会报错但结果全错。antialias=True在缩小分辨率时抑制混叠,对 112 这种小尺寸很明显。截断arr[:clip_len]保证 batch 内形状一致,不同视频的缓存长度可能不一样。
训练循环里几个参数值得展开。混合精度用torch.cuda.amp,显存能省三到四成,速度也更快;梯度裁剪阈值设 5.0,3D 网络的梯度偶尔会炸;优化器用 SGD 动量 0.9 配 cosine 退火,比 Adam 在视频任务上更稳,学习率初值 0.01 按 batch 大小线性缩放。
def train_one_epoch(model, loader, optimizer, criterion, scaler, device, use_amp=True): model.train() loss_sum, correct, total = 0.0, 0, 0 for clips, labels in loader: clips = clips.to(device, non_blocking=True) labels = labels.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) with torch.cuda.amp.autocast(enabled=use_amp): logits = model(clips) loss = criterion(logits, labels) scaler.scale(loss).backward() scaler.unscale_(optimizer) # 先反缩放再裁剪 torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) scaler.step(optimizer) scaler.update() loss_sum += loss.item() * labels.size(0) correct += (logits.argmax(1) == labels).sum().item() total += labels.size(0) return loss_sum / total, correct / totalscaler.unscale_(optimizer)必须在裁剪之前调用,否则梯度还带着缩放系数,裁剪阈值等于失效,这个顺序错了不报错,只是训练悄悄变差。set_to_none=True清梯度比置零更省内存。non_blocking=True配合pin_memory才能让数据搬运和计算重叠。
注意:较新版本的 PyTorch 已把
torch.cuda.amp迁到torch.amp,接口写成torch.amp.autocast('cuda')和torch.amp.GradScaler('cuda')。旧写法目前仍能用但会有弃用提示,升级环境时留意。
3.3 学习率、批大小与类别不平衡的处理
视频模型的 batch 很难开大,16 帧 112² 的输入下,单卡 24GB 大概只能放 8~16 个样本。batch 小会带来两个连锁问题:BatchNorm 统计量不稳,以及梯度噪声大。前者可以换成 GroupNorm 或者跨卡 SyncBN,后者靠累积梯度解决——把optimizer.step()每 4 个 batch 执行一次,等效 batch 放大四倍。
类别不平衡在行为识别里几乎必然出现,“正常行走”类别的样本量常常是“摔倒”的几十倍。两种处理方式:训练侧给CrossEntropyLoss传weight参数,按类别频次的倒数开方计算;数据侧对少数类做片段级过采样。我一般两个一起用,权重系数不要给太满,取频次倒数的平方根比直接用倒数稳定。
学习率调度用 warmup 加 cosine:前 3 个 epoch 从 0 线性升到初值,之后余弦下降到初值的 1%。3D 网络冷启动阶段梯度方向很乱,warmup 能少走不少弯路。训练 epoch 数不必太多,小数据集上 30 到 50 轮就够,配合早停看验证集。
4. 推理与部署:滑窗、投票和延迟的三角平衡
训练完模型只是拿到一个能对固定长度片段打分的函数,真实视频是变长的,还需要一层推理封装。这一层做不好,前面训练再干净也白搭。
4.1 滑窗推理与片段级投票
长视频的处理方式是滑窗:以固定步长在时间轴上切出多个clip_len的窗口,每个窗口过模型得到一组类别概率,最后融合。融合策略有两种常见形态,平均概率适合各窗口置信度接近的情况,最大置信度适合动作只在少数窗口出现的稀疏场景。工程上更常用的是加权平均,权重取每个窗口的最大 softmax 值,兼顾稳定性和灵敏度。
import torch import torch.nn.functional as F @torch.no_grad() def sliding_window_probs(model, frames, clip_len=16, stride=8, device="cuda"): """frames: (T,C,H,W) 已归一化的整段视频张量,返回 (N_windows, C) 概率""" model.eval() probs = [] starts = range(0, max(1, len(frames) - clip_len + 1), stride) for s in starts: clip = frames[s:s + clip_len] if clip.shape[0] < clip_len: # 尾部不足一窗,复制末帧补齐 pad = clip[-1:].repeat(clip_len - clip.shape[0], 1, 1, 1) clip = torch.cat([clip, pad], dim=0) logits = model(clip.unsqueeze(0).to(device)) probs.append(F.softmax(logits, dim=1).squeeze(0).cpu()) return torch.stack(probs) def aggregate(probs, topk=3): """置信度加权平均:越自信的窗口话语权越大""" w = probs.max(dim=1).values w = w / w.sum().clamp_min(1e-6) fused = (probs * w.unsqueeze(1)).sum(dim=0) values, indices = fused.topk(min(topk, fused.numel())) return values.tolist(), indices.tolist()stride是精度和延迟的直接旋钮。设成clip_len // 2是常见起点,等于 50% 重叠;延迟敏感的场景可以放大到clip_len,窗口不重叠但边界动作容易漏。clip[-1:].repeat(...)处理视频尾部长度不足的情况,比丢弃末窗更合理,因为动作往往就发生在结尾。aggregate里clamp_min防止全零权重导致除零。
4.2 导出 ONNX 与半精度推理
PyTorch 直接推理在服务端够用,但边缘设备或者需要多语言调用时,ONNX 更通用。导出时把 batch 维度设为动态,方便批处理;opset 选 17 以上,3D 算子的支持比较完整。
import torch model.eval().cuda() dummy = torch.randn(1, 3, 16, 112, 112).cuda() torch.onnx.export( model, dummy, "r2plus1d.onnx", opset_version=17, input_names=["clip"], output_names=["logits"], dynamic_axes={"clip": {0: "batch"}}, # 只开放 batch 维,T/H/W 固定更利于优化 do_constant_folding=True, )dynamic_axes只开 batch 维是有意为之。把T、H、W也设成动态会让部分算子退回通用实现,推理反而变慢;如果确实需要可变帧数,更好的做法是导出多个固定形状的模型分别加载。
推理端用 ONNX Runtime,GPU 环境下切到CUDAExecutionProvider,半精度需要先用转换工具把模型权重压成 fp16,再加载。CPU 端就老老实实 fp32,fp16 在多数 CPU 上没有加速反而更慢。
import numpy as np import onnxruntime as ort sess = ort.InferenceSession("r2plus1d.onnx", providers=["CUDAExecutionProvider"]) clip = np.random.randn(1, 3, 16, 112, 112).astype(np.float32) logits = sess.run(["logits"], {"clip": clip})[0]第一行providers的顺序决定优先级,写成列表时 ORT 会按顺序尝试,把 GPU 放前面即可。输入 dtype 必须是 fp32(除非模型已转 fp16),numpy 默认 float64 会直接报类型不匹配,这个错在联调时很常见。
4.3 后处理:平滑、阈值与连续确认
模型的逐窗输出会有抖动,单个窗口置信度 0.7 不代表真发生了动作。直接按帧阈值化,误报率会高到没法用。工程上一般叠三层后处理:时间维中值滤波、置信度阈值、连续窗口确认。
def gate(preds, scores, thr=0.6, min_run=2): """preds: 每窗预测类别;scores: 对应置信度。返回去除孤立触发后的结果""" out = [] run, cur = 0, None for p, s in zip(preds, scores): if s >= thr and p == cur: run += 1 elif s >= thr: cur, run = p, 1 else: cur, run = None, 0 if run >= min_run: out.append((p, s)) return outmin_run=2表示至少连续两个窗口都判为同一类别才输出,对应到时间轴上大约是 1 到 2 秒的持续动作,能过滤掉绝大部分瞬时误触发。阈值thr不建议拍脑袋定,用验证集扫一遍 0.4 到 0.9,画一条误报率和漏报率的权衡曲线再选。
5. 避坑与排查:五个看起来正常的翻车现场
这一章记录的都是“日志没报错、指标还挺好看、上线就露馅”的问题,共同点是原因藏得深。
5.1 数据侧的三类问题
现象一:训练集准确率 99%,验证集只有 40%,而且怎么调都上不去。原因几乎可以确定是时序泄漏——按片段随机划分,同一条视频的相邻片段同时进了训练集和验证集,模型在验证集上看到的其实是训练时见过的画面。解决方式是按视频 ID 划分,用第 2 章那个哈希函数,划分完再统计一下两边是否有重名的视频 ID 前缀,确认交集为空。划分改对之后,验证集准确率通常会掉一大截,这时候的曲线才可信。
现象二:验证准确率不低,但模型对快动作几乎全错。检查采样跨度,T / fps × 步长算下来往往不足 1 秒,模型看到的是动作的一个静止切面。解决方式是把步长调大或者把T提高到 32,让单片段覆盖 2 秒以上;同时对快动作类别单独统计准确率,不要只看总体指标,总体指标会被样本量大的慢动作类稀释。
现象三:线下评估正常,接上摄像头后颜色发灰、指标骤降。解码环节的色彩顺序或者色彩范围不一致——训练数据用 RGB 全范围,推理端解出 BGR 或者限制范围,通道和亮度都对不上。解决方式是在解码函数里强制format="rgb24",并在上线前跑一个小脚本,把同一条视频用训练和解码两套代码各解一次,逐像素比对均值方差,差超过 5% 就说明管线不一致。
5.2 训练与部署侧的问题
现象四:batch 稍微调小就出现 loss 剧烈震荡或者 NaN。3D 网络的 BatchNorm 在 batch 小于 8 时统计量噪声极大,进而污染梯度。解决方式有三种,按优先级:换 GroupNorm(改动小、稳定)、开梯度累积把等效 batch 撑到 16 以上、多卡场景换 SyncBN。先试第一种,改完观察 loss 曲线是否收敛平滑。
现象五:模型推理只要 20ms,端到端延迟却有 300ms。时间全花在视频解码上,尤其是高分辨率多路并发的场景。解决方式是把解码和推理解耦成两级流水线,解码进程按固定帧率抽帧并写入环形缓冲区,推理侧只读内存帧不碰容器;再用多进程解码按路数并行,单路延迟能压到 50ms 以内。监控上要分别埋点解码耗时和推理耗时,不然永远不知道该优化哪头。
提示:这五条里,前三条不会在日志里留下任何痕迹,只能靠设计阶段就把它堵死。数据划分和采样跨度这两个决定,落笔前多花半小时想清楚,比后面调参一周有用。
6. 进阶:用混淆矩阵和时序定位把误报压下去
总体准确率是个会骗人的指标。我现在的习惯是训练一结束先看混淆矩阵,不看准确率。做法很简单,在验证集上收集全部预测,用sklearn.metrics.confusion_matrix出一张归一化矩阵,重点看两处:对角线低于 0.6 的类别,以及非对角线上数值最大的那一对。前者说明这个类别本身特征不够,需要补数据或者加更强的时序建模;后者说明两类在模型眼里长得像,比如“挥手”和“招手”,这时候该做的是检查片段覆盖时长够不够区分两者,而不是盲目加数据量。
from sklearn.metrics import confusion_matrix, classification_report import numpy as np cm = confusion_matrix(y_true, y_pred, normalize="true") weak = np.where(np.diag(cm) < 0.6)[0] # 找弱类别 pairs = [(i, j, cm[i, j]) for i in range(len(cm)) for j in range(len(cm)) if i != j and cm[i, j] > 0.15] # 找高混淆类别对 print(classification_report(y_true, y_pred, digits=3))normalize="true"按真实类别归一化,避免大类别把小类别的错误盖住。weak和pairs这两个结果直接决定下一轮的工作方向,比盯着一个标量准确率有效得多。
第二个技巧是时序定位评估。分类指标只告诉你“这个片段里有没有这个动作”,不告诉你“动作起止时间准不准”。用滑窗推理输出逐窗概率后,把它和人工标注的区间做 IoU 比对,阈值取 0.5,能算出一个类似检测任务的平均精度。这个数字往往比分类准确率低二十个点以上,但它才是上线后用户真正感知到的质量。我通常会在验证集上把 IoU 曲线画出来,看阈值从 0.3 提到 0.7 时精度掉得多快,掉得越快说明模型对动作边界的定位越糊,该考虑加时序细化分支或者缩短滑窗步长。
最后一个不那么技术但很省事的习惯:固定一份“回归测试集”,20 到 50 条视频,覆盖所有类别和几种典型干扰(逆光、遮挡、低帧率)。每次改完数据管线或者换模型,先在这份小集合上跑一遍,输出混淆矩阵和误报数,和上一版逐个对比。它跑得快,两三分钟出结果,能挡住大部分“改了一处、坏了另一处”的回归问题。我吃过一次亏,换了归一化参数之后只看了总体指标,涨了两个点就发布了,结果某一类在弱光下的召回从 0.8 掉到 0.3,靠用户反馈才发现。从那以后每次动数据管线,我都强制走一遍这份回归集,宁可多花三分钟。希望帮到你。
本文还有配套的精品资源,点击获取