你手里的项目标题,是很多工科朋友做毕设、做工业视觉、做交互应用时都会撞上的一条路:拿MediaPipe先做人体的骨骼关键点提取,然后用Transformer把时间维和空间维的信息一块儿建模,最终输出动作类别。这个方案看着成熟,网上的教程也多,但真自己动手从头搭一遍,坑全在细节里。
这篇博文主要聊三件事:为什么是MediaPipe加时空Transformer这个组合,而不是别的;数据怎么处理才能喂给模型,而且训练能收敛;训练和落地时那些让人头秃的问题怎么排查。适合正在做动作识别项目、或者想把手势识别、装配动作合规检测这类需求快速落地的人参考。我自己在工业装配动作识别这个方向上用这套方案跑过几个实际场景,下面写的都是当时踩完坑、调完参之后留下的能直接抄作业的经验。
1. 为什么偏要用MediaPipe加时空Transformer
1.1 MediaPipe到底解决了什么问题
做动作识别之前,最大的问题不是分类,而是先把“人在画面里的状态”变成一组数学上能处理的数据。传统做法是人手标骨架框、标关键点,工作量大得离谱。MediaPipe不一样,它把这一步变成了一个开箱即用的能力,CPU上都能跑,而且单帧性能足够好,不需要额外买显卡就能实时出33个关键点的坐标和置信度。
这里面有个很多人忽略的好处:MediaPipe的关键点自带visibility置信度,这玩意儿在做后处理时特别好用。比如某帧手被身体挡住,关键点的visibility会掉到很低,后面做序列对齐或者差值填充的时候就能拿这个置信度做加权,而不是把脏数据直接丢给模型。工业场景里光照乱、动作快、人员乱动,如果没有置信度做兜底,模型经常会被几帧异常骨骼带偏。
另外MediaPipe还给了z坐标,z是相对深度估计,物理上不精确,但作为相对位置变化是够用的。比如“手臂向前伸”和“手臂向上抬”这两个动作,单靠x、y有时候区分不开,加上z通道之后分类就干净很多。
1.2 时序建模:LSTM看了都摇头
关键点序列拿到之后,下一步就是把“一段动作”识别成“一个类别”。最朴素的做法是拿LSTM、GRU来做时序建模。我在初版方案里就试过LSTM,短动作还行,比如“伸手拿起螺丝刀”这种一秒以内的动作,LSTM勉强能跟上。
但真上了产线的复合动作,比如“拿工具→对准螺丝→拧三圈→放回工具”,整个流程会持续三秒以上。LSTM有两个毛病在这里特别明显:
- 遗忘问题:前面几帧的信息在过完几秒后会被逐步冲淡,等到动作后半段,模型基本已经忘了起始姿势是什么。
- 长依赖难建模:LSTM靠门结构记忆信息,但“拧螺丝”和“拿工具”两个子动作之间的联系,需要在若干帧之后还能被注意力机制加权回来,LSTM做不到这种全局交互。
图卷积GCN也是个常见选择。问题在于GCN得先定义图结构,不同动作对关节之间依赖不一样:拧螺丝时手腕、手肘、肩之间的依赖关系非常强,走路时腿和腰之间更关键。固定图结构相当于给动作分类上了紧箍咒。
Transformer的核心优势就两条:一是长距离依赖直接靠attention建立连接,不需要像LSTM那样在门控中逐步传递;二是空间维和时间维的建模可以分开设计,时空Transformer这个词说白了就是把“每个关节是什么”和“每个关节怎么变化”拆开处理,但最终又在注意力机制里融合起来。
1.3 为什么选择稀疏关键点方案
还有一个方案路线是用Video Transformer直接处理RGB视频帧。好处是不需要姿态估计的中间步骤,端到端训练。但代价是计算量爆炸,几秒钟的视频切成几十个patch,序列长度大幅增加,在真实业务里基本没有实时性可言。
我对比过这两条路线以后选定了MediaPipe稀疏关键点路线。主要原因是:
- 数据冗余大幅降低:RGB像素里有大量背景、光照干扰,关键点序列里只保留人体结构信息,模型学起来更快。
- 隐私风险低:工业场景里很多工厂不允许直接保存人脸或清晰图像画面,关键点变成矢量后不会还原出可辨识的人体图像,合规上轻松很多。
- 预处理能力比训练网络便宜:MediaPipe本身是通用能力,不占模型的参数量。
当然,稀疏关键点也有损失,比如手部细微动作如果手腕被遮挡,单靠33个身体关键点远远不够。这时候可以并行接一路MediaPipe Hands的手部关键点,再把两路特征在Transformer端做融合。这个后面会提到,算是进阶玩法。
2. 数据准备与特征工程:决定模型上限的隐形战场
2.1 从视频帧到模型输入张量
这一步是整个项目里最枯燥但最关键的环节。MediaPipe提取出来的原始数据是一串字典,里面包含33个关键点的x、y、z和visibility。每个x、y是归一化的图像坐标(0到1之间),z是以髋部中心为原点的相对深度,visibility是0到1之间的置信度。
如果直接把33×4的原生数据丢进模型,效果通常很差。原因有几个:第一,不同人身高、离摄像头远近不一样,同一个“伸手”动作,在画面里的像素跨度完全不同;第二,人站在画面左边和右边时,x坐标整体会偏移;第三,髋部中心的位置在原始z值里已经被减去,但如果人左右晃动,坐标偏移仍然存在。
我的做法是这么构造每一帧的特征向量,维度是33×4再加几个额外的全局特征。核心是先做人体坐标系归一化:
def normalize_pose(landmarks): # 把人体中心平移到骨盆中心 hip_center = (landmarks[23] + landmarks[24]) / 2 normalized = landmarks - hip_center # 尺度归一化:用肩宽或者髋宽做标尺 shoulder_width = np.linalg.norm( normalized[11] - normalized[12] ) normalized = normalized / (shoulder_width + 1e-6) # 保留原始visibility,不参与坐标归一化 return normalized这里要注意,归一化标尺选肩宽还是髋宽,会对动作识别结果有影响。肩宽比较稳定,因为上半身动作在工业场景里出现的频率远高于下半身动作。但如果你要做的是跑步、深蹲这类下肢动作,建议用髋宽或者脚踝到髋部的距离做标尺。
除了位置坐标,我还会拼接一个每帧的速度特征,也就是这一帧关键点坐标与上一帧坐标的差值。速度特征对“动作”来说格外重要,因为纯粹的位置坐标只描述了一个静态姿势,两个相似的姿势可能对应完全不同的动作方向(同样是手臂弯曲,可能是举起也可能是放下),速度信息能把方向性补上。
2.2 时间维度的统一窗口设计
动作识别的输入是一段连续帧序列,但不同动作的持续时间不一样。“拿起螺丝刀”可能只要0.5秒,“拧三圈”可能持续2秒。如果使用固定长度的窗口,会遇到一个很尴尬的问题:窗口太短会截断长动作,窗口太长会让短动作在序列里被冗余静止帧淹没。
实际项目中我通常这么设计:先统计业务场景里所有动作的持续时长分布,然后取一个能覆盖大多数动作的窗口长度。工业装配场景里,单个原子动作的时长通常在0.3秒到3秒之间,我一般会用64帧的窗口,采集帧率设定在30fps,对应约2.1秒的时间跨度。
这个窗口不是死的。推理阶段,我会做滑窗推理,每10帧滑动一次,也就是相邻两次推理有54帧重叠。这样做的好处是分类结果会在时间轴上有平滑效应,不会出现前后两帧预测出完全不同动作的剧烈跳变。对于最终的动作切换判定,再用一个简单的投票机制,取过去15次推理结果中占比最大的类别作为最终输出。
如果窗口里某些关键点置信度太低,我会做帧级填充。填充策略不要用均值填充,那会让动作变成一坨奇怪的中间姿态。更好的办法是直接用前一帧和下一帧的有效数据做线性插值,或者简单粗暴点,把低置信度帧全部替换成最近的高置信度帧,后者在实时性要求高的时候反而更稳。
2.3 数据增强:小数据集的救命稻草
动作识别项目第一个痛点往往不是模型不好,而是标注数据太少。手上的数据可能只有几百段动作视频,打标完拆成窗口之后才几千条样本,直接训练Transformer很容易过拟合。数据增强是解决这个问题性价比最高的手段。
对关键点序列做增强,和图像增强思路类似,但约束条件更多,不能随便扭。我常用的增强策略如下:
- 随机缩放:对归一化后的坐标整体乘以一个0.8到1.2之间的随机系数,模拟不同体型的人。
- 随机旋转:对2D关键点做二维平面内的随机旋转,旋转角度控制在±15度以内。俯视视角的相机如果装歪了,这个增强尤其有用。
- 时间缩放:把整段序列随机压缩或拉伸到原来的0.8到1.2倍,模拟动作快慢不一致。
- 时间裁剪:在原始句子中随机裁出一段和固定窗口长度一样的子序列,相当于做了时间维度的平移增强。
- 关键点丢失模拟:随机把一段连续帧中的某些关节点坐标置零,同时把对应的visibility置为零,模拟遮挡。这招对模型的鲁棒性提升非常明显。
另外还有个很多项目会忽略的小技巧:类别不均衡的处理。装配动作里“等待”这类空闲动作样本量通常特别大,而“拧螺丝”这类目标动作样本很少。我一般会在训练时给样本数量多的类别设置采样权重,让模型在任何一个batch里面看到的不同类别样本数量大致均衡。这会显著提升少数类别的召回率。
3. 时空Transformer网络结构拆解
3.1 输入嵌入与位置编码的设计思路
在特征工程做好以后,输入张量的形状是[B, T, D],T是序列长度也就是窗口帧数,D是每一帧的特征维度。我说一下我用的具体配置:T=64,D=33×4+3=135。额外加的3个特征,分别是身体中心的x、y速度以及整体的运动能量(所有关节点速度的平方和)。
直接把原始特征输入Transformer不是不行,但D维度过大且各通道之间的尺度不一致(坐标是归一化后的,visibility是0到1之间的,速度则可能在某些帧上出现较大值),我建议先过一个输入映射层,把135维映射到hidden_dim=256维。这一层可以是一个简单的全连接层加LayerNorm。
位置编码分空间维和时间维两部分。空间位置编码给每个关键点一个独立的可学习嵌入,时间位置编码给每一帧一个独立的可学习嵌入。在具体实现里,这两个位置编码向量会被加到输入序列的对应token上。为什么不用正余弦固定编码?因为在关键点序列任务里,关节的先后顺序不是自然语言的语法,不同数据集上关节索引的排布可能不一样,用可学习的位置编码让模型自己去适应这种顺序关系,效果更稳。
3.2 时空注意力模块的内部逻辑
时空Transformer的核心是注意力模块。我在实际项目中采用了“分解式”结构,也就是空间注意力和时间注意力分开做,而不是像Video Transformer那样在完整的三维时空块上做注意力。
模块内部的数据流是这样的:
- 输入序列先reshape成空间token排列:把每帧的33个关键点看作33个空间token,每个token的特征维度是256。
- 空间注意力先做,在每一帧内部,33个关键点之间做self-attention。这一步建模的是“这一帧里,膝盖动了会不会对手腕的位置产生影响”这类关节间的空间关联。
- 做完空间注意力之后,把序列重新排列成时间token:同一个关键点在连续64帧上的特征被组织成64个时间token。
- 时间注意力在关键点维度上做self-attention,建模的是“这个关节在过去64帧里位置和速度的变化轨迹”。
这两步交替堆叠,总共用了4层时空注意力块。每层还有前馈网络FFN、残差连接和LayerNorm。这样的设计把完整的时空交互拆成了两个更简单的子问题,计算复杂度大幅下降,同时在动作识别任务上的效果几乎没有损失。
这里有一个实现上的细节:空间注意力阶段,所有关键点的查询、键、值都来自同一帧,所以注意力矩阵的形状是[B, T, 33, 33];时间注意力阶段,注意力矩阵的形状是[B, 33, T, T]。这两个阶段各自独立,可以很方便地用PyTorch的torch.nn.MultiheadAttention实现,或者手写scaled dot-product attention控制细节。
3.3 分类头与特征池化
Transformer输出的一系列token,最后怎么变成一个分类结果?常见做法有两种:
第一种是用一个特殊的CLS token,类似于BERT的做法。在输入序列开头拼接一个可学习的向量,让它在注意力过程中聚合全局信息,最后把CLS token对应的输出向量接一个MLP分类头。这个做法在序列分类任务里很常用。
第二种是全局平均池化,直接把所有token的输出向量取平均,再输入分类头。这个做法在动作识别里也很常见,而且实现简单,对输入序列长度的变化更稳健。
我在实际对比中发现,关键点序列任务里直接用平均池化效果不输CLS token,而且省去了一个额外的可学习参数。所以我的做法是最后一层输出的所有token先经过一个LayerNorm,然后做时间维度和空间维度的全局平均池化,得到一个256维的向量,再经过两层MLP输出类别logits。中间加了一层Dropout,丢的概率设置成0.3,主要用于防止过拟合。
3.4 模型复杂性到底有多低
很多人听到Transformer就担心算力。实际上由于我们的输入序列是33个关节点×64帧,token总量是2112个,每个token维度只有256,这个规模在Transformer家族里属于比较小的。我用显存4GB的显卡就可以轻松完成训练,单帧推理在CPU上也能跑到30ms以内,如果加上TensorRT优化,推理时间能压到10ms左右。
具体参数量大概是这样的:输入映射层135×256约3.5万参数,4层时空注意力块每层参数约140万,分类头约5万,整体模型参数量在800万左右。这个量级做端侧部署也没有压力。
4. 模型训练、评估与部署落地
4.1 训练策略和损失函数
训练阶段的输入输出设计如下:输入是一段64帧的关键点序列,输出是对应的动作类别。我用的损失函数是交叉熵损失,配合标签平滑。标签平滑参数设为0.1,这一点在动作识别里很有用,因为手工标注的动作边界本身存在模糊性,“拿起工具”和“准备拿起工具”之间的边界很难严格切分,标签交叉熵容易让模型对边界样本过于自信,平滑后可以把模型的置信度压下来一点,泛化能力会更好。
优化器选的是AdamW,初始学习率3e-4,batch size设置为64,训练约100个epoch。前5个epoch用warmup策略,学习率从0线性升到3e-4,后面用余弦退火逐步降到1e-6。
对序列型输入,我在训练时还会做一个随机窗口裁剪:每段原始动作视频里随机裁剪出64帧作为训练样本。这个策略加上前面说的各种数据增强,即便只有几百段原始视频,也能把模型训练到不错的水平。我用大概600段视频、4000多个裁剪窗口,训练出了准确率超过96%的装配动作识别模型。
4.2 评估指标不能只看准确率
动作识别项目的评估和普通图像分类不太一样,时间轴上的连续性也需要评估。我的做法是把评估阶段分成两个维度:
- 帧级准确率:每帧预测的类别和真实类别是否一致,这种评估能直观反映模型在每一个时间点上的判别能力。
- 片段级准确率:把连续预测结果做一个分段,每一段内部的预测类别保持一致(这一般要靠后处理投票来实现),然后比较整段动作的类别判断是否准确。
除了准确率,对工业场景来说F1-score比准确率更值得关注。比如“拧螺丝”这个动作在整个装配周期里可能只占20%的时间,如果模型把所有帧都预测成“等待”,准确率也有80%,但这个结果毫无价值。F1-score能同时惩罚假阴性和假阳性,业务上更有参考意义。
测试时还会统计每段动作从开始到结束被正确识别出来的时间延迟。实测下来,从动作真正开始到模型稳定输出正确类别,大约需要0.3到0.5秒,这主要来自序列窗口和投票机制的缓冲延迟。如果业务上容忍不了这个延迟,可以把窗口缩短到32帧、投票次数从15次降到5次,代价是预测稳定性会差一点。这里需要业务侧去权衡。
4.3 部署时绕不开的工程优化
训练完的模型最终要跑到生产环境里。在部署时,我一般会把整个流程分成三个独立服务模块:
第一个模块是MediaPipe关键点提取,负责把视频流变成关键点序列。这个模块单独起一个进程,用OpenCV读视频帧,送到MediaPipe处理,输出关键点。关键点提取是纯CPU密集任务,可以开多个线程并行处理不同视频流。
第二个模块是动作识别推理,接收关键点序列,做好归一化和窗口滑动的预处理后,送入Transformer模型,输出分类结果。这个模块可以用GPU服务,也可以用CPU跑,视并发量而定。模型很小,CPU上开线程池足够应对几路视频流。
第三个模块是业务逻辑层,接收动作类别后做合规判定、报警或统计。比如装配动作识别里可以设定一个标准动作序列,系统检测到操作顺序不对时实时报警。
推理阶段的预处理和后处理代码要和训练阶段完全对齐,这是最容易出问题的地方。训练时用了肩宽归一化,部署时如果忘了做,或者代码里用成了髋宽,整个模型输出基本等于猜。我建议把预处理函数写成一个独立的工具模块,训练和推理共用同一份代码,千万不要复制粘贴后改。
5. 踩坑实录与排查经验
5.1 MediaPipe关键点跳动了怎么办
MediaPipe在单人、光照充足、动作不太剧烈的情况下表现很好,但实际场景总会有各种意外。最典型的问题是:当人体部分被遮挡时(比如伸手去拿东西时手被身体挡住),关键点的位置会跳跃,甚至出现一帧手在左边、下一帧手突然跑到右边的诡异情况。
踩过几次坑之后的排查步骤是:
- 按visibility阈值过滤关键点,低于0.5的直接视为无效数据,不参与后续归一化和特征计算。
- 对过滤产生的空位做时间插值,优先用前后有效帧的坐标线性插值补齐,没有有效数据的帧直接mask掉,不输入模型。
- 如果跳动依旧严重,可能是MediaPipe的模型精度太低。MediaPipe提供了模型复杂度参数,可以设为0、1、2三档,工业场景我建议直接用最高的2档,延迟增加不大,但关键点稳定度提升明显。
- 加上一阶低通滤波或者卡尔曼滤波对关键点序列做平滑。卡尔曼滤波效果要好得多,但实现复杂度也高。如果只是做分类,一阶低通滤波加一个合适的时间常数已经够用。
5.2 多人同框时模型认出了错误的目标
很多工业场景不可能保证镜头里始终只有一个人。比如装配工位旁边有其他工人路过,MediaPipe会把多个人体的关键点全部输出。如果你只把检测到的第一个人体拿来做动作识别,很可能会抓到一个路过的无关人员。
我的解决办法:
- 设置ROI区域(Region of Interest),只保留画面指定区域内的人体骨架,ROI外的全部丢弃。
- 如果ROI内仍有多人,优先选择髋部关键点离ROI中心最近的那个人,假设是主操作工。
- 更进阶的做法是利用MediaPipe的追踪功能给每个人分配tracking ID,在连续帧中保持同一个人的骨架序列,而不是每次去重新匹配。
- 实在不行,在物理层面调整摄像头角度,让镜头从斜上方往下拍,画面里基本只会出现一个完整的人体,多人相互遮挡的概率也大幅降低。这个土办法在不少工厂里反而是最优解。
5.3 数据标注慢到怀疑人生
时序动作数据的标注是个大坑。视频里每一帧都要标注对应的动作类别,一分钟的视频按30fps算就是1800帧,如果全靠人一天最多标十几分钟的视频,眼睛都看花了。
我的建议是做“粗粒度标注加自动切分”:先用一个预训练好的弱分类器对视频做自动预标注,把类别标签按时间段打上去;人工标注员只需要检查预标注的边界是否正确,把开始和结束时间点拖动修正即可,不需要逐帧修改标签。这种方式可以把标注效率提升至少5倍。
另外一个技巧是,视频采集时让工人按照脚本分步骤做动作,每两步之间保持约1秒的“等待”状态,这样标注的时候可以很轻松地找到动作边界,不需要按帧去反复回放找起点终点。
5.4 Transformer过拟合和泛化性不稳定
如果你的动作类别很多(超过20类),而每类只有几百个窗口样本,Transformer很容易出现过拟合。训练loss不断下降,但验证集准确率上不去,这种情况我用下面几个手段解决了:
- 降低Transformer层数,从6层砍到3层甚至2层。关键点序列的特征维度远没有自然语言那么丰富,堆太多层只会让模型记住训练集的噪声。
- 把注意力头的数量从8个减到4个,通常已经足够建模关节之间的依赖关系。
- 加大数据增强强度,特别是时间缩放和关键点丢失模拟,这两个对提升泛化能力最有效。
- 加入L2正则化或者直接用早停法,监控验证集F1,一旦连续5个epoch没有提升就停止训练。
所以一个可复用的经验是:模型结构宁小勿大。动作识别项目的数据量决定了模型规模的上下限,与其堆超大模型然后到处找数据,不如把模型精简到一个刚好能容纳当前数据量的规模,训练快、调参快、部署也爽快。
5.5 动作类别之间的混淆怎么消
装配动作里“拿起扳手”和“拿起螺丝刀”这两种动作,从身体姿态上看几乎一模一样,都是弯腰伸手,关键点数据几乎区分不了。这种情况模型再强也没有用,因为输入信息根本不够。
解决思路是引入额外的信息源。一种做法是在身体关键点之外,再跑一路MediaPipe Hands手部关键点,把手部姿态特征拼接到输入特征里,看看握持手势能不能区分。还有一种做法是在工位上加一个俯视的独立摄像头,专门拍摄工作台面,识别工具的类型,再把工具识别结果作为条件输入到动作分类模型中。
从这个角度说,动作识别系统的上限往往不取决于模型结构,而取决于输入的数据是否包含了足够的判别信息。如果输入特征本身没法区分目标类别,换什么模型都白搭。
6. 后续还能往哪个方向扩展
这套方案搭好之后,后续可以扩展的方向也很明确。
一个是跨场景迁移。当换了产线换了一批人,摄像头角度不同、人体比例不同,模型效果通常会掉。这一般不需要重新训练整个模型,而是在新场景上收集少量数据做微调即可。为了降低老模型对新场景的灾难性遗忘,微调时把原模型的学习率调低一些,只训练最后两层分类头,效果通常就够用了。
另一个方向是把动作识别从离线变成在线预测。当前方案还是滑窗式的准在线模式,要拿到完整的64帧窗口才能输出分类。如果想做到真正的实时持续预测,可以用流式Transformer或者因果注意力机制,一帧一帧地更新预测,延迟可以进一步降低。
还有一个值得尝试的多模态方向,是把骨骼关键点和深度相机深度图、IMU惯性传感器数据融合在一起。骨骼关键点在遮挡情况下可靠性不足,深度图和IMU可以补足,但融合的复杂度也随之上升。
另外,大模型的出现也让动作识别多了一个新方向:用CLIP这类视觉语言模型做动作的描述性理解。但这类模型对算力要求高,实时性差,在工业场景里暂时替代不了轻量的关键点方案。未来如果边缘算力继续升级,关键点和语义描述的融合是有机会的。
我个人在实际项目里最深的体会是:不要把动作识别当成一个纯粹的模型问题。真正花时间的往往是数据质量、特征设计和后处理逻辑。MediaPipe提供了便宜可靠的前端,时空Transformer提供了灵活的建模能力,把两者正确组合起来并不是终点,之后的工程打磨才能真正决定系统好不好用。这套方案从可行性验证到落地,我用了几周时间就完成了初版,后续大部分时间都花在数据标注和系统稳定性上面。如果看完这篇,你也正准备上手一个动作识别项目,我的建议是先把数据采集和标注流程跑通,再谈模型结构,否则你会发现自己一直在和过拟合搏斗,而不是在解决问题。