硬件检测与分割教程

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

Stevlen.Fu Hongjin2026-09-15 21:28
肌电手环防"跑位"指南:一份不太正经的 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%

手环会跑位,人会出汗,模型会失忆。流程稳住了,每次戴上花一分钟校准,就能把它哄回来。

参考文献

  1. Hudgins B, Parker P, Scott RN. A new strategy for multifunction myoelectric control. IEEE Trans Biomed Eng, 1993.
  2. Englehart K, Hudgins B. A robust, real-time control scheme for multifunction myoelectric control. IEEE Trans Biomed Eng, 2003.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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.
  11. Du Y, Jin W, Wei W, Hu Y, Geng W. Surface EMG-based inter-session gesture recognition enhanced by deep domain adaptation. Sensors, 2017.
  12. 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.
  13. Rodrigues PLC, Jutten C, Congedo M. Riemannian Procrustes analysis: transfer learning for brain–computer interfaces. IEEE Trans Biomed Eng, 2019.
  14. Ketykó I, Kovács F, Varga KZ. Domain adaptation for sEMG-based gesture recognition with recurrent neural networks. IJCNN, 2019.
  15. Sivakumar V, et al. emg2qwerty: a large dataset with baselines for touch typing using surface electromyography. NeurIPS Datasets and Benchmarks Track, 2024.
点赞收藏
// 评论0
0 / 500
还没有评论,快来抢沙发