简介这套基于sEMG表面肌电与IMU惯性测量单元的手语手势识别项目面向从事人机交互、康复工程或移动感知研究的学生与开发者覆盖从数据采集到实时识别的完整流程。包内共59个文件包含Python预处理与训练脚本、C/MATLAB源码、TensorFlow模型文件checkpoint、meta、index等、CSV数据文件及Visual Studio工程文件便于直接查看与二次开发整体压缩包约39.91MB。项目附带单、双手数据采集程序数据预处理环节集成去噪、特征提取与分割并采用WMA与小波变换处理信号核心部分提供了RNN、CNN及MYO_RNN2s_v1模型和图文件可对照理解网络结构设计便于调整参数与训练策略。实时识别模块与模型文件相互配套适合希望复现sEMGIMU手势识别实验、快速搭建原型或学习神经网络在该领域应用的读者也有助于在此基础上开展动作分类与人机交互研究。资源已有678人学习下载具备一定的参考与复用价值。1. 基于sEMG和IMU的手语手势识别为什么要把肌电和惯性数据放在一起做手语手势识别在可穿戴设备上从来不是热门赛道但真到需要脱离摄像头、在移动场景里理解手语时基于sEMG和IMU的手语手势识别反而是少数能稳定落地的方案。表面肌电sEMG能直接反映前臂肌肉的发力模式手指和手腕的细小动作都会在肌电信号里留下痕迹IMU则负责手臂整体的姿态和运动轨迹正好补上肌电“能看出发力看不出位移”的短板。两者融合后数据收集、去噪、特征提取、手势分割、神经网络搭建到实时识别是一条完整链路。这篇文章写给正在做穿戴式手势交互、无障碍翻译或人机接口的工程师和学生目标是让你照着我给的流程和参数自己搭出一套能跑、能量化评估的手语识别系统并避开我已经踩过的坑。2. 数据收集sEMG与IMU同步采集的硬件选型与标注方案2.1 硬件选型与通道配置先定同步方式再定传感器手语识别数据质量的一半取决于传感器同步方式。sEMG和IMU本来就是两类频率特性完全不同的信号肌电的典型有效频段在20Hz到450Hz之间采样率通常要做到500Hz以上IMU描述的刚体运动频率低得多100Hz到200Hz就能覆盖绝大多数手部动作。两者直接拼接会带来两个问题一是时间基准不同二是空间坐标系不同。所以硬件选型的第一步不是堆通道数而是想清楚这两个问题怎么解。最常见的配置是8通道sEMG加6轴或9轴IMU。sEMG电极贴在前臂的指屈肌群和指伸肌群上沿着肌腹走向布置IMU放在腕背或靠近手背的位置用来测量手臂旋转和位移。如果选择分体式模块sEMG采集板和IMU模块各有各的晶振时间戳天然不同步如果选择腕带式集成方案很多产品把电极和IMU做在同一块柔性电路上硬件层已经统一时基能省掉后面不少事但通道数通常固定在8到16路不方便按前臂长度调整电极位置。传感器典型采样率通道数放置位置用途sEMG500Hz-1kHz8前臂屈肌群/伸肌群手指、手腕发力模式IMU加速度100Hz-200Hz3腕背手势整体加速度与倾斜IMU陀螺仪100Hz-200Hz3腕背手部旋转、角速度IMU磁力计50Hz-100Hz3腕背航向参考室内易受磁干扰关于磁力计我的建议是谨慎使用。手语识别大多在室内环境进行办公桌、显示器、金属扶手都会造成磁场畸变磁力计输出一个不稳定的航向角会拖累姿态解算。如果不是必须知道绝对朝向直接用加速度和陀螺仪做6轴姿态估计就够。关节角度、手臂指向这些特征靠线性加速度和角速度在重力方向的投影也能恢复大部分。通道数为什么定8路而不是16路因为手语词汇虽然复杂但大部分由手指屈伸、手腕翻转和前臂旋转组合而成前臂浅层能稳定采集到信号的肌群就那么几个。8路电极覆盖指浅屈肌、指深屈肌、桡侧腕屈肌、尺侧腕屈肌、指伸肌群等主要发力点已经能把大部分手形区分开。16路能拿到更细的空间分布但电极间距变小、串扰变大线缆和佩戴成本也翻倍。对这个应用场景性价比最高的起点是8路模型效果不够再考虑提高通道密度。2.2 同步采集与数据标注时间戳对齐与标签协议如果你用的是分体式模块同步采集是第一个真正会翻车的环节。常见做法是在两个采集线程里分别打上主机接收时间戳数据包内部再带一个包序号。主机收到任意一个串口的数据后记录time.time()并写入帧头之后离线对齐时用这个时间戳找对应关系。IMU采样率低一般以sEMG时间戳为基准用线性插值把IMU插到相同的采样时刻。import pandas as pd semg_df pd.read_csv(raw_semg.csv) # 列: timestamp_ms, ch1..ch8 imu_df pd.read_csv(raw_imu.csv) # 列: timestamp_ms, acc_x, acc_y, acc_z, gyro_x, gyro_y, gyro_z aligned pd.merge_asof( semg_df.sort_values(timestamp_ms), imu_df.sort_values(timestamp_ms), ontimestamp_ms, directionnearest, tolerance5 ).dropna(subset[acc_x])这段代码的逻辑是以sEMG时间戳为主键为每一个sEMG采样点找时间上最接近的IMU样本容差设为5ms。超过5ms的直接丢弃因为两个传感器时间差超过一个IMU采样周期时插值已经没有意义。注意在调用merge_asof前两个DataFrame必须按时间排序否则会报错或得到乱序结果。实时推理时不能等离线对齐需要维护两个环形缓冲区在采集线程里用同样的插值思路做在线对齐。这个细节后面避坑章节会展开。标注协议是另一个容易低估的环节。手语词汇没有一个固定的“标准时长”同一个词在不同语境下的语速差别很大所以不能用固定窗口去切。推荐的做法是让受试者按自然手语速度重复手势标注者把每个手势的起止时间写入annot.csv每一行包含start_ms, end_ms, label_id, label_name。注意sEMG的激活通常比可见动作早20ms到50ms肌肉先发力手才开始动所以标签起点可以对齐到肌电能量上升沿而不是视频里的可见动作起点。50ms内的偏差可以接受超过100ms就会影响模型对动作边界的判断。另外要专门录两类特殊的标签一类是“空状态”也就是手自然放松、不做任何手势另一类是“过渡动作”比如手从桌面移到胸前、两指准备动作等。只录目标词汇的常见后果是模型在实时识别时对空状态不停输出某个词因为训练时模型从没见过“什么都不做”的输入分布。加这两类会让类别不平衡严重一些但实时体验会好非常多。2.3 采集流程与文件组织按被试、会话、手势编号存盘数据文件组织直接决定后面能不能做留一被试验证。我一般按这个目录结构存原始数据dataset/ subject_01/ session_1/ raw_semg.csv raw_imu.csv annot.csv video.mp4 session_2/ raw_semg.csv raw_imu.csv annot.csv subject_02/ session_1/raw_semg.csv的列是timestamp_ms, ch1, ch2, ..., ch8raw_imu.csv的列是timestamp_ms, acc_x, acc_y, acc_z, gyro_x, gyro_y, gyro_z。按被试和会话分开而不是全部堆在一起是为了后续做跨受试者评估。同一个被试在同一个session里的数据高度相关如果训练集和验证集混着切模型会记住该受试者的肌肉发力习惯而不是真正学到手势的通用表示。采集流程上我建议一个session不要超过15分钟中间至少休息5分钟。肌肉疲劳之后sEMG的幅度和频谱会明显变化同一个手势在session前段和后段的特征漂移很大模型会学到疲劳伪影。每类手势重复20到30遍顺序随机打乱不要按固定列表循环否则模型会学会“下一个手势是什么”。标注时最好有视频参照用手机摄像头对着手部拍然后用一个明显的加速度冲击比如用手拍一下桌面把传感器时间轴和视频时间轴对齐这个动作在后期复核标签时非常救命。3. 数据预处理去噪、手势分割与特征提取的落地顺序3.1 sEMG去噪带通滤波、陷波与因果滤波的取舍表面肌电信号幅度在微伏到毫伏量级非常容易被工频干扰和运动伪迹污染。国内最常见的干扰是50Hz工频国外实验室代码里经常写60Hz如果你的系统有可能跨地域使用陷波频率要做成配置项而不是写死。sEMG的有效频段通常认为是20Hz到450Hz低于20Hz的直流偏置和运动伪迹要滤掉高于450Hz的噪声也要滤掉。顺序上先陷波再带通也可以把陷波和带通串成级联滤波器。import numpy as np from scipy.signal import butter, iirnotch, filtfilt, sosfiltfilt def preprocess_semg_offline(raw, fs1000, notch_freq50): # 先陷波再带通filtfilt 是零相位滤波只用于离线数据 b, a iirnotch(notch_freq, Q30, fsfs) raw filtfilt(b, a, raw, axis0) sos butter(4, [20, 450], btypeband, fsfs, outputsos) return sosfiltfilt(sos, raw, axis0)这段代码里filtfilt和sosfiltfilt都是零相位滤波意思是对信号做正向滤波再反向滤波整体不产生相位偏移非常适合离线处理。但在实时识别里不能用这种滤波器因为它把未来的数据算进了当前结果。实时端要改用因果滤波器比如lfilter或sosfilt然后接受几毫秒的群延迟。群延迟对识别的影响远远小于时间不同步不要为了那几毫秒去牺牲滤波质量。IMU信号去噪相对简单加速度和陀螺仪各做一个低通滤波就够了。但这里有一个真正需要处理的问题重力加速度补偿。加速度计测到的值是“惯性加速度重力加速度”的合成手语动作里手臂一翻转重力在三个轴上的投影就剧烈变化。如果直接把原始加速度送进模型模型很可能学的是重力方向而不是手势运动。常见做法是先做姿态解算得到四元数再把加速度旋转到世界坐标系然后减去重力分量[0, 0, 1]。这一步对手势方向泛化至关重要我在避坑章里会专门再讲。注意滤波参数不要照搬别人的设置。sEMG电极类型不同、贴放位置不同、皮肤阻抗不同最优高通截止频率会有差异。先录一段静息和一段握拳看看频谱再决定。3.2 手势分割基于能量阈值和最小持续时间的滑窗检测分割的作用是把连续数据流切成一个个手势样本。离线阶段可以用标注文件直接切但自动分割仍然要做一是用来校核标注二是实时识别时根本等不到动作结束才去识别。自动分割的核心思路是计算sEMG的短期能量包络和IMU的角速度模值两者超过阈值就认为是动作开始。我常用的参数是RMS滑动窗长度25ms步长10ms阈值取静息段能量的均值加3倍标准差活动段最短持续200ms静默段最短150ms。静息段的选取要单独录2秒“手放松垂在身侧”的数据用前2秒做基线而不是用整段数据均值。因为整段数据里大部分是手势动作均值会被拉高最终阈值太高弱手势直接漏检。import numpy as np from scipy.ndimage import binary_closing, binary_opening, label def segment_by_energy(rms_energy, gyro_norm, fs_frame, thr_semg, thr_imu): active (rms_energy thr_semg) | (gyro_norm thr_imu) structure np.ones(int(0.15 * fs_frame), dtypebool) # 150ms 的形态学结构 active binary_closing(active, structure) # 合并相近碎片 active binary_opening(active, structure) # 去除短促毛刺 labeled, n_seg label(active) segments [] for seg_id in range(1, n_seg 1): idx np.where(labeled seg_id)[0] start_ms idx[0] / fs_frame * 1000 end_ms idx[-1] / fs_frame * 1000 segments.append((start_ms, end_ms)) return segments这段代码里fs_frame是包络帧率不是原始采样率。如果RMS窗是25ms、步长10ms那么fs_frame约为100Hz。binary_closing先做闭运算把两个相隔不到150ms的动作碎片连成一个再做开运算把不足150ms的噪声毛刺删掉。这样处理之后活动段边界基本就是手势的起止位置。如果发现某些词汇的起止总是偏长或偏短优先检查RMS窗长和形态学结构长度不要盲目改阈值。3.3 特征提取时域、频域与IMU姿态特征的表头设计特征提取在深度学习路线里看起来有点过时但它仍然有不可替代的作用手工特征可以用来训练随机森林或前馈神经网络作为深度学习模型的基线当深度模型效果不好时对比手工特征能快速定位是模型容量不够还是输入信息不足。所以我保留了手工特征作为参考。特征组特征名维度每通道说明sEMG时域RMS1反映发力强度sEMG时域MAV1平均绝对值比RMS计算量小sEMG时域ZC1过零率反映信号频率变化sEMG时域WL1波形长度对幅度和频率都敏感sEMG频域MNF/MDF1平均频率/中值频率反映肌肉疲劳IMU线性加速度3去重力后的加速度IMU角速度模值1手部旋转活跃度IMU姿态角3欧拉角或四元数def extract_handcrafted_features(semg_seg, imu_seg): feats [] for ch in range(semg_seg.shape[1]): x semg_seg[:, ch] - np.mean(semg_seg[:, ch]) feats.append(np.sqrt(np.mean(x ** 2))) # RMS feats.append(np.mean(np.abs(x))) # MAV feats.append(np.sum(np.abs(np.diff(x 0)))) # ZC feats.append(np.sum(np.abs(np.diff(x)))) # WL for i in range(imu_seg.shape[1]): feats.append(np.mean(imu_seg[:, i])) feats.append(np.std(imu_seg[:, i])) return np.array(feats)需要说明的是过零率不能直接在原始信号上计算要先减掉直流分量否则一个基线漂移就会让过零率失真。WL的计算量很小但对sEMG的幅度和频率变化都很敏感适合作为轻量分类器的输入。如果后面要直接把原始波形喂给神经网络手工特征可以不做只需要做标准化。但保留一份特征基线能帮你判断模型是不是真的学到了超越手工特征的信息。4. 神经网络搭建一维卷积与BiLSTM融合的模型结构与训练策略4.1 输入组织把sEMG和IMU拼成多通道时间序列神经网络的输入最好是固定长度的滑窗样本。窗口长度我通常取500ms因为手语中一个手指动作从发力到结束大多在300ms到800ms之间500ms能覆盖大部分信息。sEMG按1kHz采样就是500个点IMU原本可能只有100Hz需要通过线性插值补到500个点。插值之后IMU的高频细节本来就少模型能学到的只是低频姿态信息不用担心插值引入额外的虚假信息。拼合方式是把sEMG的8个通道和IMU的6个通道acc、gyro按时间轴拼成一个17通道的二维数组形状是(17, 500)。这个数组作为模型输入。更精细的做法是sEMG和IMU分别用小网络编码后再融合但工程上第一种方式更容易跑通而且在这个任务上已经够用。通道标准化要非常小心在训练集上逐通道计算均值和标准差验证集和测试集必须用训练集存下来的统计量不能重新计算否则等于把验证集信息泄漏进训练过程。标准化参数保存成JSON推理阶段直接加载。4.2 模型结构一维卷积神经网络提取局部特征BiLSTM建模时序依赖纯前馈神经网络在这个任务上很难出效果因为手语手势是一个时间序列同一时刻的肌肉和姿态信息不足必须依赖前几百毫秒的上下文。纯CNN也能做但需要堆很多层才能覆盖足够大的感受野。我常用的结构是一维卷积神经网络加双向LSTM卷积层负责提取局部肌电爆发特征和IMU波形片段特征LSTM负责把不同时间步的信息关联起来。import torch import torch.nn as nn class SemgImuGestureNet(nn.Module): def __init__(self, n_classes20): super().__init__() self.encoder nn.Sequential( nn.Conv1d(17, 32, kernel_size9, padding4), # 1700ms 局部窗口 nn.BatchNorm1d(32), nn.ReLU(), nn.Conv1d(32, 64, kernel_size5, padding2), nn.BatchNorm1d(64), nn.ReLU(), nn.Dropout(0.3) ) self.lstm nn.LSTM(64, 64, bidirectionalTrue, batch_firstTrue) self.head nn.Sequential( nn.Linear(128, 64), nn.ReLU(), nn.Linear(64, n_classes) ) def forward(self, x): # x: (batch, 17, 500) x self.encoder(x) # (batch, 64, 500) x x.transpose(1, 2) # (batch, 500, 64) out, _ self.lstm(x) # (batch, 500, 128) out out.mean(dim1) # (batch, 128) return self.head(out)第一层卷积核大小9在1kHz采样率下相当于9ms的窗口足够捕获单个肌电动作电位波形第二层卷积核大小5相当于5ms。两层卷积都不做池化保留完整时间分辨率让LSTM能看到每一步的细节。LSTM用双向结构是因为离线训练时一个样本的完整窗口已经拿到前后文都可用实时推理时因为窗口是固定的双向LSTM在窗口内仍然可以做因果处理只是最后一个时间步的输出会隐含整个窗口的信息。mean(dim1)对所有时间步取平均比只取最后一个时间步更稳定模型不依赖特定时间步的位置。这个模型参数量很小大约几十万在几千个样本的小数据集上不容易过拟合。如果你的数据量更大可以加大LSTM隐藏层到128或者加一层卷积数据量小的时候增加参数只会让验证指标越来越难看。4.3 训练策略留一被试交叉验证、类别不平衡与数据增强训练集和验证集划分是手语识别最容易自欺欺人的地方。同一个受试者的数据绝不能同时出现在训练集和验证集中否则模型记住了这个人的肌肉发力习惯换个人就崩。我一般用留一被试交叉验证有10个受试者就训练10折每折拿1个人做验证其余9个人做训练最终报告10折的平均准确率。这种评估方式才是真实穿戴场景的性能下限。类别不平衡在加入“空状态”和“过渡动作”后必然出现。最简单的处理是用compute_class_weight计算每个类别的权重然后给损失函数传入weight。也可以对空状态做欠采样。我倾向保留全部空状态数据用加权损失因为实时识别最怕误触发空状态样本越多模型对静息输入的抑制越强。from sklearn.utils.class_weight import compute_class_weight import numpy as np classes np.unique(all_labels) weights compute_class_weight(balanced, classesclasses, yall_labels) class_weights torch.tensor(weights, dtypetorch.float32).to(device) criterion nn.CrossEntropyLoss(weightclass_weights)数据增强方面sEMG加5%的高斯噪声时间轴随机拉伸0.9到1.1倍IMU的三维向量绕重力方向随机旋转一个小角度。这些增强模拟的是佩戴位置偏移和手部朝向变化。增强幅度不能太大sEMG加噪超过10%会把肌电信号淹没时间拉伸超过1.1倍会让手语节奏失真旋转角度超过20度会改变手势的实际语义方向有可能制造错误样本。训练配置不需要花哨AdamW优化器学习率1e-3权重衰减1e-4批次大小64最大训练60个epoch早停patience设为10。每轮记录验证集损失连续10轮不下降就恢复最佳模型权重。如果验证集损失在训练早期就停住先检查输入标准化是否是按训练集统计量做的如果准确率始终只有随机水平检查标签和分割窗口的对应关系这两处是出现“训练了但学不到”最常见的原因。5. 实时识别与部署的避坑清单时间同步、延迟、重力补偿与采样率5.1 坑一sEMG和IMU时间不同步离线训练和实时结果差距巨大现象离线训练准确率能做到95%部署到实时识别只有60%同一个动作第一次识别对第二次就错。原因离线数据处理时用merge_asof把两个传感器按时间戳对齐了实时代码却只是简单地从两个串口读到原始数据后直接拼接。两个模块的晶振不同时钟漂移造成几十毫秒的错位在快速手势里几十毫秒足够让sEMG的发力峰值和IMU的角速度峰值错开模型看到的“肌肉发力对应姿态变化”关系是乱的。解决在线程里为每个数据包打上主机接收时间戳并维护最近100对的平均时间差。如果时间差超过5ms丢弃当前包或重新插值。更稳妥的做法是建立两个环形缓冲区把IMU样本按时间戳插值到sEMG采样点上每收到一个sEMG数据包就触发一次对齐和推理。不要等到两个流都攒满再处理那样延迟会叠加。5.2 坑二把离线分割逻辑搬到实时识别延迟高到没法用现象手语动作已经做完了界面上的识别词要等500ms才出来或者手势还没结束就提前识别成另一个词。原因离线预处理用了filtfilt双向滤波整个手势段完成后才能计算离线分割又要求“检测到动作结束”才知道这是一个完整手势。这两件事在实时系统里都不能直接做。实时系统只能看到当前时刻之前的数据任何双向滤波都会引入等于窗口长度的延迟任何“等结束再识别”的策略都会让输出晚一拍。解决实时端改用因果滤波直接用lfilter群延迟只有几毫秒识别端改为滑动窗口持续推理用输出平滑器抑制抖动。我常用的参数是窗口500ms步长50ms模型每50ms给一个预测再用一个长度为5的滑动投票器连续3次投同一个类别才输出。这样输出延迟约150ms对实时手语识别来说可接受。from collections import deque votes deque(maxlen5) def online_infer(frame_tensor): pred model(frame_tensor).argmax().item() votes.append(pred) if votes.count(votes[-1]) 3: return votes[-1] return -1 # -1 表示保持静默5.3 坑三IMU原始加速度里带重力模型方向泛化差现象训练集里“水平向左”的手势识别得很好“水平向右”总是错受试者手臂稍微倾斜一点就崩溃。原因加速度计输出的是“惯性加速度重力加速度”的混合体重力约1g而手部手势产生的加速度往往只有0.1g到0.5g。手臂一倾斜重力在三个轴上的投影大幅变化原始加速度分布完全变样模型学到的是重力方向而不是手势运动方向。解决必须做IMU重力加速度补偿。常见做法是用Madgwick或互补滤波算法由陀螺仪和加速度计估计姿态四元数再把加速度从机体坐标系旋转到世界坐标系最后减去重力向量[0,0,1]。不需要绝对航向只要重力方向估计准线性加速度就能反映真实手势运动。姿态解算频率和IMU采样率保持一致先解算再插值对齐顺序不能反。5.4 坑四为了省电把采样率降到25Hz快速手势全被吞掉现象桌面慢速演示时一切正常戴到手腕上做快速拍手、手指轻弹等动作时识别率明显下降模型输出来回跳。原因sEMG的有效频段最高到450HzIMU也跟不上快速翻转25Hz采样会把高频肌电折叠到低频快速手势的细节被彻底混叠。有些工程师为了在嵌入式板子上省电把采样率降得很低这是性价比最低的优化方式。解决sEMG采样率至少保持500HzIMU至少100Hz。算力不够就在软件端降采样不要在硬件端降采样。更合理的低功耗方案是让系统在空闲时进入低功耗待机用sEMG能量阈值检测有效肌电激活后再把采集和推理升到全速手势结束后再恢复待机。这样省电省的是待机时间而不是采样频率。6. 验证方法留一交叉验证、流式回放与误触发统计模型训练完之后我至少会跑三层验证缺一层都会在真人测试时被发现。第一层是离线留一被试交叉验证画出混淆矩阵逐类计算精确率和召回率。重点看两类错误一是“同轨迹不同手形”混淆比如手腕转动方向相同但手指屈伸不同二是“同手形不同轨迹”混淆比如手指弯的弧度相同但手臂移动方向不同。这两种混淆说明模型依赖的信号源不对前者要多靠sEMG区分后者要多靠IMU区分。第二层是流式回放。把采集时存储的原始数据包按实时识别流程逐包读入让模型在一个模拟的流式环境里输出预测再把预测结果和离线标签做时间对齐。这一步能发现延迟、滤波参数和滑动投票器的问题。我会统计从真实手势开始到系统首次输出正确类别的时间差目标控制在300ms以内如果超过500ms实时体验就会显得反应迟钝。第三层是真实佩戴测试但只测误触发率还不够。我自己的习惯是录一段10分钟的自由手语对话里面包含大量空状态和过渡动作统计系统在这段时间里输出了几次错误手势词。误触发每10分钟超过3次就说明空状态抑制不够需要调大投票器阈值或增加空状态样本权重。这里给你一个流式回放的骨架逻辑加载原始数据包按时间戳顺序逐包交给同步模块和模型记录每次推理输出最后与标签对齐。这个脚本会非常枯燥但每次模型迭代都要跑一遍和离线训练同样重要。我的教训是再漂亮的离线指标到了真人的手腕上都会因为佩戴位置偏移和动作速度波动掉一截。所以每次改完网络或预处理参数我都会先跑一遍流式回放再安排新受试者做一轮快速体验把混淆矩阵和误触发次数抄进实验记录本作为下一个版本的基准。这套验证闭环虽然没有花哨的可视化却是让系统真正可用的钥匙。希望帮到你。本文还有配套的精品资源点击获取