肌电手环防"跑位"指南:一份不太正经的 sEMG 手势分类教程

前言:模型失忆事件
典型剧情是这样的:新到的肌电手环插上串口,八路波形哗哗往外涌,你觉得自己离钢铁侠只差一个下午。晚上训了个手势分类器,准确率 95%,截图发群,收获一片"牛"。
第二天重新戴上,模型当场失忆:握拳认成张手,屈腕认成"我也不知道"。
模型其实没坏,是手环跑位了。每次穿戴,电极落在胳膊上的位置都不一样。模型学会的不是"握拳",而是"昨天那个位置上的握拳"。
这篇教程把整套流程走一遍:先让手环少跑位,再让模型不怕跑位,然后把评估做诚实,最后给以后的连续控制留一扇后门。
0. 认清敌人:误差从哪来
手环上的差分电极一般沿前臂纵向排布。沿着手臂上下滑一两厘米,电极底下还是那块肌肉,主要是幅值变了。但只要绕手臂转过一个电极间距,每一路底下的肌肉就集体换人。这就像乐队全员换座位:谱子没变,鼓手坐到了钢琴前面。文献里 1 cm 量级的偏移就足以让分类误差明显上升(Young 2011、2012)。
| 误差来源 | 表现 | 主要对策 |
|---|---|---|
| 绕手臂旋转(头号敌人) | 各通道空间模式整体错位 | 画线定位、每次校准、训练时加偏移数据 |
| 沿手臂上下滑 | 幅值变化 | 固定纵向距离 |
| 松紧、出汗、接触 | 单个通道增益和噪声变化 | 魔术贴刻度、预热、通道质检 |
| 当天状态、发力习惯 | 幅值和共收缩模式变化 | 多 session 采集、分档力度 |
1. 开工前三件事:先别急着训模型
验收链路。 录 60 s 静息,用帧计数算出实际采样率和丢帧率。标称 2 kHz 不等于电脑真收到了 2 kHz。串口高波特率下丢帧很常见,WL、ZC 这类特征会被悄悄带歪,而你会以为是模型的锅。顺手看一眼频谱上 50 Hz 及其谐波有多嚣张。有线版商家特别叮嘱过:笔记本拔掉电源适配器、用电池供电采集,线别拖在桌面地面,做动作时别拽到它。
建通道映射表。 戴着手环逐根轻敲电极条,看串口哪一路跳起来,记下"第几路 → 哪根电极",再用尺子量出各电极的间距。后面所有和几何有关的操作都靠这张表。
确认电极是否等距。 以我手上这个手环为例:连续 6 根电极,接着 4 个空节,主机壳上再 2 根。等距的环形手环可以把"旋转"当成"通道号循环移位"来处理,emg2qwerty 训练时就是随机移 ±1 个通道做数据增强。不等距的手环别照抄:跨过空节移位,相当于把 6 号选手塞进一个根本不存在的座位。
2. 物理防跑位:全篇性价比最高的一步
算法再花哨,也比不上一支记号笔。
旋转基准用尺骨后缘,也就是前臂尺侧从手肘一路摸到手腕的那条骨头边。用皮肤记号笔沿它画一条纵线,每次让主机壳的同一条边对齐这条线。纵向基准从肱骨外上髁(手肘外侧那个骨头包)往手腕方向量一个固定距离,把数字写进实验记录。胳膊上画线看起来很像术前标记,室友问起来解释一句就好。
魔术贴上也画几道刻度,保证每次松紧一致:太松电极会漂,太紧手会紫。戴的时候前臂保持中立位(拇指朝上),免得旋前旋后时皮肤带着手环一起转。戴好之后静置 3–5 分钟再开始,干电极需要一点时间和皮肤"熟络",微微出汗后接触阻抗才稳定。
目标:周向误差小于半个电极间距。
3. 采集协议:让数据自带以后用得上的标签
v1 先做五类:静息、握拳、张手、屈腕、伸腕。这几个动作用的是大肌群,屈侧和伸侧的模式差别明显,适合先把管线跑通。捏取、伸食指这类精细动作留给 v2。
动作用梯形力度提示:屏幕上 1 s 上升、3 s 保持、1 s 下降,分轻、中两档(静息不分档),每类每档做 5 次,一个 session 大约 8 分钟。分档是因为真实用户不会每次都用全力握拳,除非他和这个手环有仇。分类只用保持段,两端各切掉 0.5 s,去掉过渡和反应延迟。斜坡段先存着,以后做连续控制时就是现成的力度标签。
session 至少 5 个,分布在 2 天以上;另外故意偏移 ±1 个电极位各录 1 个,专门测模型怕不怕跑位。每个 session 开头录一段校准:5 s 静息,加每类保持 1 次。后面两条管线都靠这段数据把模型"哄"到新位置上。
4. 预处理与特征
4.1 因果滤波:训练和上线必须是同一套
20 Hz 高通去运动伪迹和基线漂移,450 Hz 低通,再加 50 Hz 及其谐波陷波。重点是全部用因果 IIR,离线训练也一样。filtfilt 会偷看未来:训练时相当于开卷考试,上线后变成闭卷,特征分布对不上。
python
import numpy as np
from scipy import signal
FS = 2000 # 标称值,以第 1 节实测为准
def make_sos(fs=FS):
hp = signal.butter(4, 20, btype="highpass", fs=fs, output="sos")
lp = signal.butter(4, 450, btype="lowpass", fs=fs, output="sos")
notch = [signal.tf2sos(*signal.iirnotch(f, Q=30, fs=fs))
for f in range(50, 451, 50)] # 50 Hz 及谐波
return np.vstack([hp, lp, *notch])
class CausalFilter:
"""流式因果滤波:滤波器状态跨数据块保留"""
def __init__(self, n_ch, fs=FS):
self.sos = make_sos(fs)
self.zi = np.zeros((self.sos.shape[0], 2, n_ch))
def __call__(self, chunk): # chunk: (n_samples, n_ch)
y, self.zi = signal.sosfilt(self.sos, chunk, axis=0, zi=self.zi)
return y
filt = CausalFilter(n_ch=8)
# 串口每来一块数据就调用一次 filt(chunk);离线训练也按同样方式喂
分块喂和一次性喂的结果完全一致,所以离线和在线用的是同一个滤波器。
4.2 切窗:200 ms 窗,50 ms 步长
窗太短特征不稳,窗太长用户觉得假手在思考人生。150–250 ms 是经典的平衡点(Smith 2011)。
python
def sliding_windows(x, win=400, step=100): # 2 kHz 下 = 200 ms / 50 ms
idx = range(0, len(x) - win + 1, step)
return np.stack([x[i:i + win] for i in idx]) # (n_win, win, n_ch)
4.3 TD4 特征:肌电界的四大金刚
MAV(平均绝对值)、WL(波形长度)、ZC(过零次数)、SSC(斜率变号次数),出自 Hudgins 1993,三十多年了还在一线服役。ZC 和 SSC 需要一个阈值防止噪声乱计数,阈值取当次静息段各通道噪声 std 的 k 倍,k 在 1–3 之间扫一下。
python
def td4(w, th):
"""w: (n_win, n_samp, n_ch);th: 每通道阈值 = k × 静息噪声 std"""
d = np.diff(w, axis=1)
mav = np.abs(w).mean(axis=1)
wl = np.abs(d).sum(axis=1)
zc = ((w[:, :-1] * w[:, 1:] < 0) & (np.abs(d) >= th)).sum(axis=1)
ssc = ((d[:, :-1] * d[:, 1:] < 0) &
((np.abs(d[:, :-1]) >= th) | (np.abs(d[:, 1:]) >= th))).sum(axis=1)
return np.hstack([np.log(mav + 1e-12), np.log(wl + 1e-12), zc, ssc])
# th = 2 * rest_windows.std(axis=(0, 1)) # k = 2
# F = td4(windows, th) # (n_win, 32):8 通道 × 4 特征
5. 两条分类管线
A 是老实人基线,B 是专治跑位的选手。两条都做,放进同一套评估里比。
5.1 管线 A:TD4 + 自适应 LDA
可以这样理解 LDA:协方差是"性格",类均值是"今天的状态"。重新戴一次手环,性格变化不大,状态会变。所以校准段只更新类均值,协方差沿用历史数据的 pooled 估计(adaptive LDA 思路,Vidovic 2016)。
理由很朴素:8 通道 × 4 特征 = 32 维,协方差有 528 个自由参数,而 30–60 s 校准数据切出来的窗口高度重叠。拿它估均值够用,估协方差就是在猜。
python
import copy
from sklearn.covariance import ledoit_wolf
from sklearn.preprocessing import StandardScaler
class AdaptiveLDA:
def fit(self, X, y):
self.classes_ = np.unique(y)
self.means_ = np.stack([X[y == c].mean(axis=0) for c in self.classes_])
Xc = X - self.means_[np.searchsorted(self.classes_, y)]
self.cov_, _ = ledoit_wolf(Xc, assume_centered=True) # 收缩估计的 pooled 协方差
return self._refresh()
def adapt(self, X_cal, y_cal, lam=0.8):
"""只用校准数据更新类均值,协方差不动"""
for i, c in enumerate(self.classes_):
if np.any(y_cal == c):
self.means_[i] = lam * X_cal[y_cal == c].mean(axis=0) + (1 - lam) * self.means_[i]
return self._refresh()
def _refresh(self): # 等先验的 LDA 判别函数
self.W_ = np.linalg.solve(self.cov_, self.means_.T).T
self.b_ = -0.5 * np.sum(self.W_ * self.means_, axis=1)
return self
def predict_proba(self, X):
s = X @ self.W_.T + self.b_
s -= s.max(axis=1, keepdims=True)
p = np.exp(s)
return p / p.sum(axis=1, keepdims=True)
scaler = StandardScaler().fit(F_train)
lda = AdaptiveLDA().fit(scaler.transform(F_train), y_train)
# 新 session:先复制一份,再用带标签的校准段更新类均值
lda_new = copy.deepcopy(lda).adapt(scaler.transform(F_cal), y_cal)
proba = lda_new.predict_proba(scaler.transform(F_new))
TD4 跑通之后,可以换成 TD-PSD 特征对比一下,它对发力大小的变化更稳(Al-Timemy 2016)。
5.2 管线 B:协方差 + 黎曼 re-center
黎曼几何听起来像数学系劝退课,实际用起来就几行代码。
直觉是这样的。把跑位近似成通道之间的线性混合 x' ≈ A·x,协方差就变成 C' ≈ A·C·Aᵀ。每个 session 用自己校准段协方差的黎曼均值当"原点",把所有协方差挪回单位阵附近,这一步叫 re-center。可以证明,做完之后 A 只剩下一个正交变换的残影(Zanini 2018;想继续对齐这个残余旋转,可以看 Riemannian Procrustes Analysis,Rodrigues 2019)。相当于每次开会前,先把所有人拉回同一条起跑线。
python
from pyriemann.estimation import Covariances
from pyriemann.tangentspace import TangentSpace
from sklearn.linear_model import LogisticRegression
try: # pyriemann >= 0.12
from pyriemann.geometry.mean import mean_riemann
from pyriemann.geometry.base import invsqrtm
except ImportError: # 更老的版本
from pyriemann.utils.mean import mean_riemann
from pyriemann.utils.base import invsqrtm
cov_est = Covariances(estimator="oas")
def covs(w): # w: (n_win, n_samp, n_ch)
return cov_est.fit_transform(w.transpose(0, 2, 1)) # pyriemann 要 (n, ch, time)
def recenter(C, C_cal):
P = invsqrtm(mean_riemann(C_cal)) # 本 session 的"原点"
return P @ C @ P
# 每个训练 session: dict(win=全部窗口, y=标签, cal=该 session 校准段的窗口)
C_tr = np.concatenate([recenter(covs(s["win"]), covs(s["cal"])) for s in train_sessions])
y_tr = np.concatenate([s["y"] for s in train_sessions])
ts = TangentSpace(metric="riemann").fit(C_tr)
clf = LogisticRegression(max_iter=1000).fit(ts.transform(C_tr), y_tr)
# 新 session:校准段不需要标签,但动作组成要和训练时一致
C_new = recenter(covs(new["win"]), covs(new["cal"]))
proba = clf.predict_proba(ts.transform(C_new))
两个注意点:
第一,re-center 不需要标签,但各 session 校准段的动作组成必须一致,否则"原点"本身就是歪的。以后改成用日常使用中的无标签数据在线更新原点时,要防止占 80% 时间的静息把原点拽跑。
第二,线性混合只是近似,偏移越大越不准,因为边缘电极会看到训练时从没见过的肌肉。所以第 2 节的记号笔不能省。
6. 输出端:投票 + 拒识
端着咖啡时突然识别成"张手",你的咖啡和键盘会一起记住这一天。所以输出端要加两道保险。第一道,最大后验概率低于阈值就输出静息,宁可漏一个动作也别乱动。第二道,用最近 3–5 帧做多数投票(Englehart & Hudgins 2003)。投票帧数别贪多,越稳越像树懒。
python
from collections import Counter, deque
class Decider:
def __init__(self, classes, rest, p_th=0.7, n_vote=5):
self.classes, self.rest, self.p_th = classes, rest, p_th
self.buf = deque(maxlen=n_vote)
def __call__(self, proba): # proba: 单帧各类概率
k = int(np.argmax(proba))
self.buf.append(self.classes[k] if proba[k] >= self.p_th else self.rest)
return Counter(self.buf).most_common(1)[0][0]
decide = Decider(classes=clf.classes_, rest=0) # 0 = 静息的标签
# 每 50 ms 来一帧:cmd = decide(p)
7. 攒够数据后再加的料
等 session 攒到 5–10 个、准备上神经网络时,这些就派上用场了。
训练集要混入多次独立穿脱和故意偏移的数据,这是减轻跑位掉点最直接的办法(Hargrove 2008)。数据增强有三件套。第一是逐通道随机增益,模拟松紧和接触变化。第二是只在连续电极内部做相邻插值 (1−α)·x[i] + α·x[i±1],α 取 0 到 0.5,模拟不足一格的旋转。第三是随机通道 dropout,模拟某根电极接触不良。
新 session 的适配也有两招。有校准标签时,冻结网络主干,只训练输入端一个 8×8 线性层,用单位阵初始化,这一层正好扮演上面那个 A(Ketykó 2019)。没有标签时用 AdaBN,拿新佩戴后 1–2 分钟的数据只刷新 BN 统计量(Du 2017)。
再往后是在线无监督适应和人机共适应:手环在适应人,人也在适应手环。那是下一篇的故事。
8. 评估:四个数,一个都不能少
| 编号 | 怎么测 | 回答什么问题 |
|---|---|---|
| ① session 内 | 按重复次数留一交叉验证 | 管线本身行不行 |
| ② 跨 session,无校准 | 留一个 session 当测试集 | 穿脱一次掉多少 |
| ③ 跨 session,有校准 | 校准段只用来适配,不进测试集 | 校准能救回多少 |
| ④ 偏移 session | 用故意偏移的 session 测 | 对跑位有多敏感 |
千万别把所有窗口随机打散再切训练集和测试集。相邻窗口重叠 75%,这等于开卷考试还提前看了答案,得出的 99% 只能拿来发朋友圈。
还有个坏消息:离线准确率和实际好不好用的相关性有限(Ortiz-Catalan 2015),驾校科目一满分不代表敢上高速。离线过关后一定要做在线测试,看按提示完成动作的成功率和完成时间。在线测试大概率会撞上"手臂姿势效应":手放桌上时是学霸,手一抬就成学渣(Fougner 2011)。所以 v2 采集要加 2–3 种手臂姿势。
9. 给连续控制留后门
从第一天起,就把解码器输出定义成"各自由度的速度或目标位置"向量。分类阶段输出 one-hot × 速度,以后换成回归模型,下游控制代码一行都不用改。
路线分两步走。先做"分类 + 比例速度":类别决定动哪个自由度,归一化幅值决定动多快。再做同时比例回归。回归需要的标签有两个现成来源:第 3 节存下来的斜坡段力度,或者用深度相机 + MediaPipe 同步采集的手部关节角。
10. 一页纸清单
| 阶段 | 做什么 | 翻车信号 |
|---|---|---|
| 链路验收 | 实测采样率与丢帧、看 50 Hz、建通道映射表 | 实测采样率和标称对不上 |
| 佩戴 | 尺骨后缘画线、外上髁定距、魔术贴刻度、中立位、静置 3–5 min | 每次戴完活动模式都不一样 |
| 采集 | 5 类 × 轻中两档 × 5 次梯形提示,每个 session 带校准段 | 保持段里混进了斜坡 |
| 处理 | 因果滤波、200/50 ms 窗、TD4 | 训练时用了 filtfilt |
| 分类 | A 自适应 LDA / B 黎曼 re-center,加投票和拒识 | 端咖啡时突然张手 |
| 评估 | 四个数 + 在线测试 | 随机切窗口得到 99% |
手环会跑位,人会出汗,模型会失忆。流程稳住了,每次戴上花一分钟校准,就能把它哄回来。
参考文献
- Hudgins B, Parker P, Scott RN. A new strategy for multifunction myoelectric control. IEEE Trans Biomed Eng, 1993.
- Englehart K, Hudgins B. A robust, real-time control scheme for multifunction myoelectric control. IEEE Trans Biomed Eng, 2003.
- Hargrove L, Englehart K, Hudgins B. A training strategy to reduce classification degradation due to electrode displacements in pattern recognition based myoelectric control. Biomed Signal Process Control, 2008.
- Young AJ, Hargrove LJ, Kuiken TA. The effects of electrode size and orientation on the sensitivity of myoelectric pattern recognition systems to electrode shift. IEEE Trans Biomed Eng, 2011.
- Young AJ, Hargrove LJ, Kuiken TA. Improving myoelectric pattern recognition robustness to electrode shift by changing interelectrode distance and electrode configuration. IEEE Trans Biomed Eng, 2012.
- Smith LH, Hargrove LJ, Lock BA, Kuiken TA. Determining the optimal window length for pattern recognition-based myoelectric control: balancing the competing effects of classification error and controller delay. IEEE Trans Neural Syst Rehabil Eng, 2011.
- Fougner A, Scheme E, Chan ADC, Englehart K, Stavdahl Ø. Resolving the limb position effect in myoelectric pattern recognition. IEEE Trans Neural Syst Rehabil Eng, 2011.
- Ortiz-Catalan M, Rouhani F, Brånemark R, Håkansson B. Offline accuracy: a potentially misleading metric in myoelectric pattern recognition for prosthetic control. IEEE EMBC, 2015.
- Al-Timemy AH, Khushaba RN, Bugmann G, Escudero J. Improving the performance against force variation of EMG controlled multifunctional upper-limb prostheses for transradial amputees. IEEE Trans Neural Syst Rehabil Eng, 2016.
- Vidovic MM-C, Hwang H-J, Amsüss S, Hahne JM, Farina D, Müller K-R. Improving the robustness of myoelectric pattern recognition for upper limb prostheses by covariate shift adaptation. IEEE Trans Neural Syst Rehabil Eng, 2016.
- Du Y, Jin W, Wei W, Hu Y, Geng W. Surface EMG-based inter-session gesture recognition enhanced by deep domain adaptation. Sensors, 2017.
- Zanini P, Congedo M, Jutten C, Said S, Berthoumieu Y. Transfer learning: a Riemannian geometry framework with applications to brain–computer interfaces. IEEE Trans Biomed Eng, 2018.
- Rodrigues PLC, Jutten C, Congedo M. Riemannian Procrustes analysis: transfer learning for brain–computer interfaces. IEEE Trans Biomed Eng, 2019.
- Ketykó I, Kovács F, Varga KZ. Domain adaptation for sEMG-based gesture recognition with recurrent neural networks. IJCNN, 2019.
- Sivakumar V, et al. emg2qwerty: a large dataset with baselines for touch typing using surface electromyography. NeurIPS Datasets and Benchmarks Track, 2024.
