4. 肌电特征提取:时域特征、频域特征、时频域特征
好,咱们进入正题。上一章聊了怎么把原始肌电信号弄干净,那接下来呢?
说白了,干净的信号还是太「原始」了。你没法直接拿它去控制假肢。我们需要从中提炼出一些有代表性的数值,这就是特征提取。我个人习惯把特征分成三大类:时域的、频域的、还有时频域的。咱们一个一个来。
核心思路: 特征提取就是把一段高维的肌电信号,压缩成几个低维的、能反映肌肉活动状态的关键数值。选对了特征,假肢控制就成功了一半。
4.1 时域特征:简单、直观、计算快
时域特征,就是直接在时间轴上算信号的各种统计量。好处是计算量小,嵌入式系统跑起来毫无压力。我在早期的假肢项目里,基本全靠这几个特征撑场面。
4.1.1 平均绝对值(MAV)
MAV 是最常用的特征之一。它反映了信号的整体幅值大小。肌肉收缩越用力,MAV 值就越大。
# Python 示例:计算 MAV
import numpy as np
def compute_mav(emg_signal):
# emg_signal 是一维数组
mav = np.mean(np.abs(emg_signal))
return mav
# 假设你有一段 200ms 的肌电数据
sample_signal = np.random.randn(200) * 0.1 # 模拟数据
mav_value = compute_mav(sample_signal)
print(f"MAV = {mav_value:.4f}")
我的经验: MAV 对噪声比较敏感。如果信号基线没调好,MAV 会飘。我曾经遇到过因为电极接触不良,MAV 值忽大忽小,导致假肢误动作。后来我养成了一个习惯:每次采集前先测一段静息状态的 MAV,作为基线补偿。
4.1.2 均方根(RMS)
RMS 和 MAV 有点像,但它更强调信号的能量。你可以把 RMS 理解为信号的「有效值」。在控制假肢时,RMS 通常比 MAV 更稳定一些。
def compute_rms(emg_signal):
rms = np.sqrt(np.mean(emg_signal ** 2))
return rms
rms_value = compute_rms(sample_signal)
print(f"RMS = {rms_value:.4f}")
4.1.3 过零点数(ZC)
ZC 统计的是信号穿过零点的次数。它反映了信号的频率信息。肌肉疲劳时,信号的频率会下降,ZC 值也会变小。
def compute_zc(emg_signal):
# 需要设置一个阈值,避免噪声引起的误判
threshold = 0.01
zc = 0
for i in range(1, len(emg_signal)):
if (emg_signal[i-1] * emg_signal[i] < 0) and \
(abs(emg_signal[i] - emg_signal[i-1]) > threshold):
zc += 1
return zc
zc_value = compute_zc(sample_signal)
print(f"ZC = {zc_value}")
注意: ZC 一定要加阈值!不加阈值的话,一点点噪声就会让 ZC 值爆炸。我刚开始做的时候吃过这个亏,特征值完全没法用。
4.1.4 斜率符号变化数(SSC)
SSC 统计的是信号斜率符号变化的次数。它和 ZC 类似,但反映的是信号波形的变化剧烈程度。
def compute_ssc(emg_signal):
threshold = 0.01
ssc = 0
for i in range(1, len(emg_signal)-1):
diff1 = emg_signal[i] - emg_signal[i-1]
diff2 = emg_signal[i+1] - emg_signal[i]
if (diff1 * diff2 < 0) and \
(abs(diff1) > threshold or abs(diff2) > threshold):
ssc += 1
return ssc
ssc_value = compute_ssc(sample_signal)
print(f"SSC = {ssc_value}")
4.2 频域特征:看信号的能量分布
时域特征虽然好用,但它丢掉了信号的频率信息。你想想看,不同的手势,肌肉放电的频率成分是不一样的。频域特征就是干这个的。
4.2.1 平均频率(MNF)
MNF 是功率谱的加权平均频率。它告诉你信号的能量主要集中在哪个频率附近。
from scipy import signal
def compute_mnf(emg_signal, fs=1000):
# 计算功率谱密度
freqs, psd = signal.welch(emg_signal, fs, nperseg=256)
# 计算平均频率
mnf = np.sum(freqs * psd) / np.sum(psd)
return mnf
mnf_value = compute_mnf(sample_signal)
print(f"MNF = {mnf_value:.2f} Hz")
4.2.2 中值频率(MDF)
MDF 是功率谱的累积能量达到 50% 时的频率。它比 MNF 更鲁棒,不容易受极端值影响。
def compute_mdf(emg_signal, fs=1000):
freqs, psd = signal.welch(emg_signal, fs, nperseg=256)
cumsum = np.cumsum(psd)
# 找到累积能量达到 50% 的频率点
mdf = freqs[np.where(cumsum >= cumsum[-1] / 2)[0][0]]
return mdf
mdf_value = compute_mdf(sample_signal)
print(f"MDF = {mdf_value:.2f} Hz")
避坑指南: 计算频域特征时,窗长的选择很关键。窗太长,时间分辨率低;窗太短,频率分辨率低。我一般用 256ms 的窗,配合 50% 的重叠,效果比较均衡。
4.3 时频域特征:兼顾时间和频率
时域和频域特征各有局限。时域特征丢了频率信息,频域特征丢了时间信息。那有没有办法两者兼顾?有,时频分析。
4.3.1 短时傅里叶变换(STFT)
STFT 的思路很简单:把信号切成一小段一小段,对每一段做傅里叶变换。这样你就能看到频率随时间的变化了。
def compute_stft(emg_signal, fs=1000):
f, t, Zxx = signal.stft(emg_signal, fs, nperseg=256)
# Zxx 是复数矩阵,取幅值
spectrogram = np.abs(Zxx)
return f, t, spectrogram
f, t, spec = compute_stft(sample_signal)
print(f"STFT 输出维度:频率点 {len(f)},时间点 {len(t)}")
4.3.2 小波变换(WT)
小波变换比 STFT 更灵活。它用不同尺度的小波基去匹配信号,在低频处有好的频率分辨率,在高频处有好的时间分辨率。
import pywt
def compute_wavelet(emg_signal):
# 使用 db4 小波,进行 5 层分解
coeffs = pywt.wavedec(emg_signal, 'db4', level=5)
# coeffs[0] 是近似系数,coeffs[1:] 是细节系数
return coeffs
coeffs = compute_wavelet(sample_signal)
print(f"小波分解层数:{len(coeffs)}")
注意: 时频特征虽然信息丰富,但计算量大。在嵌入式假肢上,我一般只用 STFT 的某些频带能量作为特征,不会把整个时频谱都扔给分类器。否则,算力扛不住。
4.4 知识体系总览
为了让你更直观地理解这些特征之间的关系,我画了一张图。
4.5 特征选择的一些建议
特征不是越多越好。我见过有人一口气提取了 20 多个特征,结果分类效果反而变差了。为什么?因为特征之间可能存在冗余,甚至互相干扰。
- 实时控制场景: 我建议用 MAV + RMS + ZC 就够了。计算快,效果稳定。
- 手势识别场景: 可以加上 MNF 或 MDF,提高对不同手势的区分度。
- 疲劳检测场景: 频域特征更合适,MDF 的下降趋势能很好地反映肌肉疲劳。
- 复杂动作场景: 考虑用 STFT 的频带能量,或者小波系数。
我的习惯: 先选 3-5 个核心特征,跑一遍分类器看看效果。如果不够,再逐步增加。不要一上来就搞「特征工程大礼包」,那样反而容易翻车。
好了,这一章的内容就到这里。特征提取是假肢控制中承上启下的关键一步。选对了特征,后面的分类和控制就会轻松很多。下一章,咱们聊聊怎么用这些特征去识别不同的手势。