3. 肌电信号预处理:滤波去噪、基线校正、信号分段与加窗
好,咱们进入正题。上一章聊了肌电信号怎么采集,那信号拿到手之后呢?说实话,直接从皮肤上捞上来的信号,简直就是一锅乱炖。有心电的干扰、工频的嗡嗡声、还有你手抖带来的基线漂移。所以,预处理这一步,说白了就是“洗菜”——把有用的肌肉活动信号挑出来,把垃圾扔掉。
我个人习惯把预处理分成四个步骤:滤波去噪、基线校正、信号分段、加窗处理。咱们一个一个来拆解。
3.1 滤波去噪:把“噪音”请出去
肌电信号的有效频率范围,一般在 20 Hz 到 500 Hz 之间。低于 20 Hz 的,大多是运动伪迹(你胳膊动的时候电极线晃了);高于 500 Hz 的,基本是高频电路噪声,没啥用。所以,第一步就是做一个带通滤波器。
核心参数:
- 高通截止频率: 20 Hz(去掉低频漂移)
- 低通截止频率: 500 Hz(去掉高频噪声)
- 滤波器阶数: 我一般用 4 阶 Butterworth,相位畸变小,够用。
嗯,这里有个坑。50 Hz 的工频干扰(欧洲是 50 Hz,美国是 60 Hz)特别顽固。你想想看,周围全是电线,这个频率的信号会直接耦合进你的肌电里。怎么办?加一个陷波滤波器(Notch Filter)。
避坑指南: 我曾经在实验室里折腾了一下午,发现采集到的信号总是有规律的波动。后来才发现,是旁边的电源插座离得太近。所以,除了软件陷波,硬件上也要注意屏蔽和接地。
下面是一个简单的 Python 代码示例,用 scipy 实现带通 + 陷波:
import numpy as np
from scipy import signal
def preprocess_emg(data, fs=1000):
# 带通滤波器:20-500 Hz
b, a = signal.butter(4, [20, 500], btype='band', fs=fs)
filtered = signal.filtfilt(b, a, data) # 零相位滤波,避免延迟
# 陷波滤波器:50 Hz
b_notch, a_notch = signal.iirnotch(50, 30, fs)
filtered = signal.filtfilt(b_notch, a_notch, filtered)
return filtered
注意,我用了 filtfilt 而不是 lfilter。为什么?因为 filtfilt 是零相位滤波,不会让信号产生时间延迟。对于假肢控制来说,延迟就是灾难,你想想看,你手都握紧了,假肢还没反应,那用户体验得多差。
3.2 基线校正:让信号“归零”
滤波之后,信号看起来干净多了。但你会发现,信号的中线(基线)可能不在零上。这是因为电极的极化电压、或者皮肤表面的电位差导致的直流偏置。
基线校正很简单:减去信号的均值。但要注意,这个均值应该是“静息态”下的均值,也就是你肌肉完全放松时的信号均值。
我的习惯: 在每次采集开始前,先让受试者放松 2 秒钟,记录这段静息信号,计算它的均值。然后用整个信号减去这个均值。这样比直接减全段均值更准确。
def baseline_correction(signal, rest_samples):
# rest_samples 是静息态的信号片段
baseline = np.mean(rest_samples)
return signal - baseline
说白了,这一步就是让“没用力”的时候信号是 0,这样后续的幅值分析才有意义。
3.3 信号分段:把连续信号切成“动作块”
肌电信号是连续的,但假肢控制需要识别“动作”。比如你握拳、张开、手腕旋转,每个动作持续几百毫秒。所以,我们需要把连续信号切成一个个时间窗口。
分段有两个关键参数:
- 窗口长度: 太短了(比如 50 ms),特征不稳定;太长了(比如 500 ms),控制延迟太大。我一般用 150-250 ms,这是一个平衡点。
- 窗口重叠: 为了平滑控制输出,相邻窗口之间通常有重叠。比如窗口长度 200 ms,步长 50 ms,那重叠率就是 75%。
经验之谈: 我在做实时假肢控制时,发现 200 ms 窗口 + 50 ms 步长效果最好。既保证了特征稳定性,又把延迟控制在了 250 ms 以内——这是人能够接受的“无延迟感”上限。
def segment_signal(signal, window_len=200, step_len=50, fs=1000):
window_samples = int(window_len * fs / 1000)
step_samples = int(step_len * fs / 1000)
segments = []
for start in range(0, len(signal) - window_samples, step_samples):
segment = signal[start:start + window_samples]
segments.append(segment)
return np.array(segments)
3.4 加窗处理:防止“频谱泄露”
分段之后,每个窗口的信号其实是一个“截断”的信号。如果你直接对这个截断的信号做傅里叶变换(提取频域特征),会在频谱上产生“泄露”——就是能量从主频扩散到旁边去了。
解决办法?加窗。常用的窗函数有 汉宁窗(Hanning) 和 海明窗(Hamming)。它们的作用是让窗口两端的信号平滑地衰减到零,减少截断效应。
我个人推荐: 汉宁窗。它在主瓣宽度和旁瓣衰减之间取得了很好的平衡。海明窗的旁瓣更低,但主瓣更宽,会损失一点频率分辨率。
def apply_window(segment, window_type='hann'):
if window_type == 'hann':
win = np.hanning(len(segment))
elif window_type == 'hamming':
win = np.hamming(len(segment))
else:
win = np.ones(len(segment)) # 矩形窗,不推荐
return segment * win
嗯,这里要注意:加窗之后,信号的幅值会变小(因为两端被衰减了)。如果你后续要提取时域特征(比如均方根 RMS),记得做幅值补偿。不过大多数情况下,我们提取的是相对特征,影响不大。
知识体系总览
为了让你更直观地理解整个预处理流程,我画了一张图:
你看,整个流程是线性的:原始信号 → 滤波去噪 → 基线校正 → 信号分段 → 加窗处理 → 输出。每一步都有它的道理,跳过了哪一步,后面提取特征的时候都会出问题。
好了,预处理这部分就聊到这儿。下一节我们会讲怎么从这些处理好的信号片段里提取特征——那才是真正让假肢“听懂”你肌肉意图的关键。