ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SSVEP脑机接口代码实战:预处理、CCA/FBCCA与TRCA识别全攻略

SSVEP脑机接口代码实战:预处理、CCA/FBCCA与TRCA识别全攻略 简介面向脑机接口与生物医学信号处理学习者的SSVEP研究代码库覆盖脑电信号预处理、频域分析、特征提取与分类识别的完整流程适合研究生、工程师用于科研入门或课堂教学演示。压缩包共三十四个文件其中三十个为MATLAB脚本辅以mex编译接口、一份PDF说明文档和一个MAT数据文件整体体积约九百四十一KB轻量便携、易于部署。代码按功能模块划分既提供滤波器设计、眼电伪迹去除、傅里叶变换等预处理工具也实现CCA、TRCA、FBCCA等主流SSVEP识别算法并包含信息传输率、分类准确率等评估指标计算脚本。通过运行示例可完整复现SSVEP数据分析流程深入理解视觉刺激设计、实时检测与脑机接口系统构建的关键技术为后续算法改进与应用开发提供可靠基础。目前已有八百二十人学习使用特别适合作为算法验证与教学演示的参考工具。1. SSVEP_code_ssvep_为什么拿到代码还是跑不出稳定的脑机接口识别率SSVEP_code_ssvep_这类仓库名看起来直白实际指向的是脑机接口里最经典的稳态视觉诱发电位SSVEP信号处理代码。屏幕上几个方块按不同频率闪烁人盯住其中一个枕区头皮上就能记录到对应频率的脑电响应识别这个响应就知道用户在看哪个目标。不少入门者拿这类代码跑在线demo预处理跑通、分类器一接准确率却只有随机水平问题通常不出在代码本身而是参考信号构造、窗口长度和通道选择这几个黑匣子没调对。这篇笔记按预处理、CCA/FBCCA、TRCA、避坑、实时化的顺序把一套能落地复现的SSVEP pipelines讲清楚适合正在复现仓库代码、做课程设计或者要搭建自己实验管线的工程师参考。2. 从原始脑电到干净片段SSVEP的预处理管线与三个必须确认的参数SSVEP预处理看起来只是“滤波切片”四步走真正决定后续分类上限的其实是三个参数采样率、通道顺序、刺激频率精度。这三个参数错了后面的CCA再标准也救不回来。先搞清楚数据格式再跑MNE最后用频谱图验证预处理效果这条管线走完才谈得上算识别率。2.1 数据格式与公开数据集的地雷选错通道顺序会全盘皆输SSVEP公开数据集最常见的两种格式是MATLAB的.mat和EEGLAB的.set。.mat文件里数据矩阵的排列五花八门常见的有通道×时间×试次、试次×时间×通道两种还见过时间×通道×试次的变体。拿到文件第一步不是急着滤波而是把矩阵维度打出来确认排列这一步能省下一个下午的排错时间。import scipy.io as sio import numpy as np mat sio.loadmat(subject1.mat) # 数据集里通常有 data、srate、freqs 之类的键 for k, v in mat.items(): if hasattr(v, shape): print(k, v.shape)这段代码的作用是扫一遍.mat文件里所有带shape的变量。看到键名后再决定用哪个字段比如矩阵是(n_trials, n_channels, n_times)还是(n_channels, n_times, n_trials)直接决定后面转置怎么写。如果数据集自带montage文件还要把电极名和EEG系统的通道序号对应上比如BioSemi和国际10-20系统的枕区通道位置并不相同。注意.mat里的events可能不是从0开始计时有的数据集以刺激起始为0有的以记录开始为0。切片前先看tmin和tmax的单位别把tmax4当毫秒传进去。把数据转成MNE的Epochs对象是常见做法。我自己习惯用EpochsArray直接构造因为很多SSVEP数据集已经把试次切好了没必要再用RawEpochs绕一圈。import mne # data 形状可能是 (trials, channels, times)先确认好再转置 data mat[data] if data.ndim 3: # 假设原始排列是 channels × times × trials转成 trials × channels × times data data.transpose(2, 0, 1) srate int(mat[srate].squeeze()) ch_names [str(c) for c in mat.get(ch_names, [O1, Oz, O2, POz])] info mne.create_info(ch_namesch_names, sfreqsrate, ch_typeseeg) epochs mne.EpochsArray(data, info, tmin0)这段代码的重点是tmin0代表每个试次的起点就是刺激出现时刻。如果数据集里已经把试次切好EpochsArray是最不容易出错的做法。如果原始数据是连续记录那就得用mne.find_events配合刺激marker做Epochs这一步不同数据集差异很大有的marker编码在模拟通道里有的在事件文件里先花五分钟看README比什么都强。2.2 降采样、带通滤波与坏道处理三个参数一次设对SSVEP的刺激频率通常在6到15Hz加上35Hz以内的谐波就够用了。采样率256Hz是很多公开数据集的出厂设置512Hz也没问题但没必要保留到1000Hz以上——降采样到250Hz不仅够用还能把后续CCA的计算量砍掉一半。滤波器的上下限我一般设0.5到50Hz0.5Hz以下的高通是为了去掉基线漂移50Hz以上直接切掉是为了避开高频肌电。# 降采样到 250 Hz前提是原始采样率高于 250 if srate 250: epochs.resample(250) # 带通滤波 0.5 ~ 50 Hzfirwin 设计线性相位滤波器 epochs.filter(0.5, 50, fir_designfirwin) # 只保留枕区通道SSVEP 响应主要在 O1/Oz/O2/POz 附近 picks mne.pick_channels(epochs.ch_names, include[O1, Oz, O2, POz]) epochs.pick(picks)resample放在filter前面是刻意的先降采样能减少滤波计算量而firwin滤波器本身不会受之前降采样的影响。通道选择这一步很多人偷懒不做直接拿全通道算CCA结果额叶通道把噪声带进来识别率反而下降。枕区四通道在这类任务上基本够用如果数据集没有标注国际10-20坐标就先查一下数据采集时用的电极帽型号。坏道处理容易被忽略。SSVEP实验时间长了电极容易干涸某个通道会出现一条平直线或者50Hz工频特别大。检查方法很简单逐通道算方差方差接近0的通道直接踢掉。# 逐通道方差方差极低的通道视为坏道 var_per_ch np.var(epochs.get_data(), axis(0, 2)) bad_chs [epochs.ch_names[i] for i, v in enumerate(var_per_ch) if v 1e-6] if bad_chs: epochs.drop_channels(bad_chs) print(dropped bad channels:, bad_chs)阈值1e-6不是通用标准要看你数据单位是uV还是V。如果数据是标准uV平直线方差就是0很容易检测如果是V单位阈值要改到1e-12。这类细节就是“看似能跑、换数据就翻车”的重灾区。2.3 频域验证怎么确认刺激频率真的被诱发出来了预处理做完别急着算分类先画频谱图验证数据里到底有没有SSVEP响应。这一步五分钟就能完成能省掉后面所有白费功夫的迭代。把某个刺激频率下的试次平均后做FFT看目标频率处有没有明显峰值。import matplotlib.pyplot as plt # 取出某个试次的枕区平均信号 X epochs.get_data() # (trials, channels, times) trial X[0] # 第一个试次 signal trial.mean(axis0) # 假设刺激频率是 10 Hz看 10 Hz 处的幅度 n len(signal) freqs np.fft.rfftfreq(n, d1 / 250) amp np.abs(np.fft.rfft(signal - signal.mean())) / n mask (freqs 5) (freqs 45) plt.plot(freqs[mask], amp[mask]) plt.xlabel(Hz) plt.ylabel(amplitude) plt.show()这段代码把第一个试次的枕区信号平均后做FFT正常情况下在刺激频率及其谐波处能看到明显的尖峰。如果频谱图上全是一片平先检查刺激频率是不是真的在数据里记录了再查滤波上限是不是把谐波切没了。如果单个试次信号太噪把同一刺激频率下的多个试次叠加平均再做FFT峰会更明显。# 叠加平均多个试次先按标签或刺激频率分组 trials_by_freq {f: [] for f in target_freqs} for i, ev in enumerate(epochs.events): pass # 按事件标记分组填入对应刺激频率叠加平均是SSVEP分析里最常用的信噪比提升手段也是后面TRCA这类方法的基础操作。预处理做到这一步数据是否可用已经有了定论接下来才轮到识别算法的选择。3. 从CCA到FBCCA把SSVEP识别代码写明白选对参考信号与滤波器组预处理拿到的是一段段干净的脑电信号识别要做的是判断这段信号“像”哪个刺激频率。CCA典型相关分析是最经典的SSVEP识别方法不需要训练数据、直接和正弦参考信号做相关性匹配是它最大的优势FBCCA在CCA基础上增加多重滤波器组把谐波信息利用得更充分识别率能往上拉。这一章把两者的实现逻辑和参数设置一次讲透。3.1 CCA的最小实现从参考信号构造到分类CCA的原理可以一句话概括找两个线性组合让脑电信号和参考信号之间的相关系数最大。SSVEP里参考信号是目标频率的正弦和余弦对比如10Hz刺激就构造sin(2π·10t)、cos(2π·10t)、sin(2π·20t)、cos(2π·20t)这一组。对每个候选频率都构造一组参考信号算一次CCA相关系数最大的那个频率就是识别结果。def make_reference(freq, srate, duration, n_harmonics3): 构造某频率的参考信号基频 n_harmonics-1 个谐波的正余弦对 t np.arange(0, duration, 1 / srate) ref [] for h in range(1, n_harmonics 1): ref.append(np.sin(2 * np.pi * freq * h * t)) ref.append(np.cos(2 * np.pi * freq * h * t)) return np.asarray(ref).T # (samples, harmonics*2) def cca_corr(x, y): 计算两组信号的最大典型相关系数 n x.shape[0] x x - x.mean(axis0) y y - y.mean(axis0) Cxx x.T x / n Cyy y.T y / n Cxy x.T y / n Cyx y.T x / n # 求解广义特征值问题最大特征值的平方根就是最大典型相关 eigvals np.linalg.eigvals(np.linalg.inv(Cxx) Cxy np.linalg.inv(Cyy) Cyx) return np.sqrt(np.max(eigvals).real)参考信号构造里的duration要和脑电片段长度严格一致差一个采样点矩阵乘法都会报维度错误。CCA这一步用到了矩阵求逆如果脑电通道数多于时间点数或者谐波数设得太多导致参考信号和脑电之间的协方差矩阵不满秩np.linalg.inv会直接翻车。加一个小的正则项是常见做法后面避坑章节会专门讲。分类循环写起来就十几行def classify_cca(epoch, freqs, srate, duration): epoch: (channels, times)返回预测频率 n_harmonics 3 rho [] for f in freqs: ref make_reference(f, srate, duration, n_harmonics) rho.append(cca_corr(epoch.T, ref)) idx int(np.argmax(rho)) return freqs[idx], rho[idx]循环里每个候选频率都要做一次CCA。候选频率个数从2个到40个不等频率间隔常用0.2Hz到1Hz。频率间隔太小的话参考信号之间的相关性本来就高识别容易混淆所以间隔至少取0.5Hz对大多数应用来说1Hz间隔已经足够。3.2 FBCCA的滤波器组设计为什么能比CCA高10个点FBCCA比CCA多的不是魔法而是对谐波分量做了分频带处理。CCA只做一次全频带相关把基频和谐波混在一起FBCCA则是先对脑电做多重带通滤波每个子带覆盖不同的谐波范围再分别做CCA最后把多个相关系数加权合并。滤波器组的划分我常用的方案是子带i的带宽范围是[1, 40/i] Hz。也就是说第一个子带覆盖1到40Hz第二个覆盖1到20Hz第三个覆盖1到13.3Hz以此类推。这样做是因为谐波次数越高、信噪比越低限制高频后能压抑噪声。def fbcca_corr(epoch, freq, srate, duration, n_bands5): FBCCA 的加权相关系数计算 eeg epoch.T # (samples, channels) rho_bands [] # 权重系数按带序衰减常见的经验参数是 i^(-1.25) 0.25 w [i ** (-1.25) 0.25 for i in range(1, n_bands 1)] for i in range(1, n_bands 1): lo 1.0 hi 40.0 / i # 用 FIR 滤波器分离子带 filtered mne.filter.filter_data(eeg.T, srate, lo, hi, verboseFalse) ref make_reference(freq, srate, duration, n_harmonics5) rho_bands.append(cca_corr(filtered.T, ref)) rho sum(r * w_ / sum(w) for r_, w_ in zip(rho_bands, w) for r in []) # 上式写起来别扭实际直接用下面的加权和 return sum(r * w[i] for i, r in enumerate(rho_bands)) / sum(w)上面这个函数代码里有一处我写错了逻辑行实际上应该是def fbcca_corr(epoch, freq, srate, duration, n_bands5): eeg epoch.T rho_bands [] w [i ** (-1.25) 0.25 for i in range(1, n_bands 1)] for i in range(1, n_bands 1): hi 40.0 / i filtered mne.filter.filter_data(eeg.T, srate, 1.0, hi, verboseFalse) ref make_reference(freq, srate, duration, n_harmonics5) rho_bands.append(cca_corr(filtered.T, ref)) return sum(r * w[i] for i, r in enumerate(rho_bands)) / sum(w)注意这里n_harmonics5比CCA的3要高因为FBCCA本身通过子带分解已经把谐波信息分开处理了这时候给更高阶谐波机会是有益的。权重衰减w的设定玄学成分确实有一点但i^(-1.25)0.25是经过不少论文验证的经验配置直接用问题不大。FBCCA在信噪比较低的数据上提升明显四分类任务里常见能比CCA高8到15个百分点。但代价是计算量翻了几倍每个候选频率要跑5次带通滤波加5次CCA。实时系统里可以考虑只对最高置信度的两三个候选频率跑FBCCA其余频率用CCA粗筛。3.3 参考信号参数表采样率、时长与谐波数怎么配参考信号构造里最容易被忽视的是时长精度。脑电片段时长是4秒但实际采样点数是4×采样率如果采样率是250Hz就是1000个点。构造参考信号时t np.arange(0, duration, 1/srate)可能得到999或1001个点和脑电矩阵列数不匹配时CCA直接报维度错误。稳妥做法是直接用脑电的点数生成时间轴n_samples epoch.shape[1] t np.arange(n_samples) / srate这是血泪经验里排得上号的一类坑看着代码没问题一跑就维度对不上。参数配置上我给一张日常实验的参考表参数CCA推荐值FBCCA推荐值说明谐波数2~35谐波太多会引入噪声FBCCA子带已做频段隔离频率间隔1 Hz0.5~1 Hz间隔过小时参考信号间本身就高度相关窗口长度1~4 s1~4 s短窗口延迟低但识别率差2s是常见平衡点子带数不适用4~6子带太多高阶带全是噪声边际收益为负上限频率50 Hz40 Hz40Hz以上对SSVEP识别贡献极小参数表给人一个起点但实际实验里最忌“一把参数走天下”。同一个滤波组划分在不同受试者上的效果可能差很多最好做一个离线小实验扫描几个方案再固定参数。我见过不少项目在参数上偷懒结果受试者一换准确率就掉最后质疑算法本身其实算法没变是参数不适配。4. TRCA与训练策略当数据集变大时识别率还能再上一档CCA和FBCCA不需要训练数据属于“零样本”方法但它们的性能也有天花板。TRCA任务相关成分分析利用带标签的训练数据学习空间滤波器把与任务相关的成分放大、把噪声压下去在数据量充足时比FBCCA再高几个点。这一章讲清楚TRCA为什么有优势、训练/交叉验证的代码怎么写、以及窗口长度和训练试次数怎么配。4.1 TRCA和CCA的本质区别一个不学、一个要学CCA直接把脑电和正弦模板做相关不利用任何已有试次的信息。TRCA的思路完全不同同一个刺激频率下有多次重复实验每次实验中任务相关成分是一致的噪声是随机的。TRCA找一个空间滤波器w让滤波后信号在同一类别的试次之间相关性最大。直观理解就是把每次实验都出现的“公共成分”提出来把随机噪声滤掉。TRCA的数学问题最终也落到广义特征值分解分子是同一类试次间的协方差累积分母是信号的总体方差。求出来的最大特征值对应的特征向量就是空间滤波器。需要注意的是TRCA是监督方法每个刺激频率需要单独求一个滤波器训练数据量直接决定滤波器质量。4.2 跨试次训练与交叉验证的代码骨架TRCA的空间滤波器计算是核心但代码本身并不复杂。关键是先把数据组织好每个刺激频率的数据堆成一个三维矩阵(trials, channels, times)然后按类别求滤波器。def trca_filter(X, target_idx): X: (n_trials, n_channels, n_times) 某一频率下的所有试次 target_idx: 需要计算滤波器的试次下标 trials X[target_idx] n_tr, n_ch, n_tp trials.shape S np.zeros((n_ch, n_ch)) # 类内试次两两计算互相关矩阵并累加 for i in range(n_tr): for j in range(i 1, n_tr): xi trials[i] - trials[i].mean(axis1, keepdimsTrue) xj trials[j] - trials[j].mean(axis1, keepdimsTrue) S xi xj.T xj xi.T # 方差矩阵 Q 用所有试次的平均协方差近似 Q np.zeros((n_ch, n_ch)) for i in range(n_tr): xi X[i] - X[i].mean(axis1, keepdimsTrue) Q xi xi.T Q / len(X) # 广义特征值分解最大特征值对应的向量就是空间滤波器 vals, vecs np.linalg.eigh(S, Q) return vecs[:, np.argmax(vals)]这段代码里有编码陷阱要特别说明np.linalg.eigh的第二个参数是b它要求b是正定矩阵但TRCA里的Q在实际数据上可能接近奇异尤其是训练试次少或者通道间高度相关时。我会给Q加上一个小的正则项比如Q 1e-6 * np.eye(n_ch)避免广义特征值分解崩溃。训练流程上交叉验证必须做而且要做对空间滤波器的拟合只能用训练集测试集只能被动投影。这点和机器学习里的标准流程一致但SSVEP领域里常见翻车是先用全部数据求出滤波器再去做交叉验证评估这样测试集被“看过”识别率虚高3到5个百分点。正确的交叉验证循环要放在滤波器拟合之前。def trca_decode(X_train, y_train, X_test, freqs): filters {} for f in freqs: idx np.where(y_train f)[0] filters[f] trca_filter(X_train[idx], np.arange(len(idx))) # 测试时空间滤波后和参考信号做相关 preds [] for trial in X_test: scores [] for f in freqs: w filters[f] filtered w.T trial # (n_times,) ref make_reference(f, srate, duration, n_harmonics3) scores.append(np.corrcoef(filtered, ref)[0, 1]) preds.append(freqs[np.argmax(scores)]) return np.array(preds)TRCA的测试阶段不是用CCA那套典型相关而是直接算空间滤波后信号与参考信号的皮尔逊相关系数。这一步空间滤波器已经把任务相关成分增强了简单相关就够用。如果希望再稳一点可以同时用原始信号算CCA分数和TRCA分数做加权融合这就是常见的集成策略。集成时两类分数量纲不同需要先做z-score标准化再加权。权重可以固定为0.5/0.5也可以离线搜。集成之后识别率通常能再涨两三个点代价是代码复杂度上升不少。我一般先把TRCA单独跑通再考虑集成毕竟集成引入的调参维度会翻倍。4.3 窗口长度与训练试次数怎么配1秒、2秒、4秒的选择窗口长度是SSVEP里对识别率影响最大的单个参数。窗口短到0.5秒时FFT频率分辨率只有2Hz两个间隔1Hz的刺激根本分不开窗口长到4秒以上频率分辨率够了但用户每做一次选择要盯着屏幕4秒可用的体验几乎是崩溃的。做离线分析时可以用长窗口把准确率刷到很高但做实时系统前一定要重新评估短窗口下的表现。训练试次数和窗口长度要一起看。训练试次太少时TRCA的滤波器拟合不稳定性能甚至不及FBCCA训练数据多到一定程度TRCA的优势才稳定体现。经验数据大致是每类10次训练试次、1秒窗口时TRCA和FBCCA差距在3个点以内每类20次以上、2秒窗口时TRCA能稳定领先5个点以上。窗口长度的选择还要考虑刺激频率间隔。频率间隔1Hz时窗口至少2秒才能保证两个频率在频谱上可分辨频率间隔0.5Hz时4秒窗口也会经常混叠。设计实验时建议频率间隔不低于1Hz这样在1到2秒窗口下还有得调。至于单次实验里做多少次选择那属于实验设计问题但记住用户疲劳后EEG信噪比下降比任何算法参数都明显。5. SSVEP代码落地避坑5个高频翻车点与排查清单SSVEP代码跑通不难跑稳很难。这一章把我在复现和调试过程中遇到的5个高频问题按“现象-原因-解决”列出来。这些问题很多不是代码逻辑错误而是数据和参数交互出来的坑排查时按清单走能省下大量时间。5.1 症状矩阵求逆报错或识别率卡在随机水平现象跑CCA时np.linalg.eigvals直接扔出奇异矩阵错误或者识别率在四分类任务上稳定在25%附近。原因协方差矩阵不满秩。常见有两种情况一是窗口时间点数和参考信号列数通道数×2×谐波数接近甚至更少二是通道之间高度共线比如枕区相邻通道信号几乎一致。解决给协方差矩阵对角加正则项Cxx lambda * Ilambda取1e-6到1e-3之间。如果加了正则还是报错检查窗口长度是否太短、谐波数是否设太高。另一个实用做法是把通道从全通道收缩到枕区4到5个通道通道少了协方差矩阵更容易满秩。5.2 症状频谱图上刺激频率处没有峰值现象预处理做完FFT检查时目标频率处没有尖峰或者峰值出现在别的频率上。原因刺激频率和滤波参数冲突。最常见的是带通滤波上限设得太低比如刺激频率是35Hz却用了1到30Hz的滤波另一个坑是数据集里刺激频率标注的是刷新率换算值实际屏幕刷新率是60Hz时35Hz的刺激实际呈现频率被分帧量化成了36Hz。解决先确认刺激频率和滤波上限之间至少有5Hz余量。显示器刷新率导致的频率漂移只有换刺激方案才能根治但至少要先查数据集的说明文件里是否标注了“有效频率”。如果检查后发现只有个别试次没有峰值再确认是不是试次切分对齐出了问题比如事件标记偏移了半个屏幕刷新周期。5.3 症状换了一个数据集或电极帽后识别率暴跌现象同一个代码在A数据集上跑出90%的准确率换成B数据集掉到50%。原因通道布局不同。A数据集用的国际10-20系统枕区通道叫O1、Oz、O2B数据集可能用的自定义电极布局枕区通道叫P5、PO3或者枕区通道缺失。直接按通道名选择时代码选到的根本不是枕区信号。解决拿到新数据集后先打印通道名列表对照电极布局实际位置重选通道。不确定时就先用全通道跑一版再对比只选枕区的结果用topomap可视化看哪个通道在刺激频率上幅度最大。这也是为什么我一直建议预处理阶段就保留完整的通道信息不要急着drop非枕区通道。5.4 症状训练时准确率高测试时一塌糊涂现象TRCA在训练集上交叉验证有85%拿到新受试者或新session的数据直接掉到随机水平以下。原因跨受试者泛化问题。TRCA学到的空间滤波器对个体差异非常敏感一个受试者的滤波器直接套到另一个受试者身上基本不可用。更隐蔽的情况是测试数据来自同一个人但是不同时间采集电极位置稍微动了几个毫米滤波器性能就崩了。解决面向新受试者时至少采集一小段校准数据每类5到10个试次重新拟合滤波器。没有校准条件时退回FBCCA它不需要训练数据泛化反而更好。如果你在做的是一个面向多用户的产品优先把FBCCA作为保底方案TRCA只在校准数据充足时启用。5.5 症状计算量大实时系统帧率上不去现象FBCCA加TRCA集成后单次分类延迟超过500毫秒在线demo卡顿明显。原因逐候选频率、逐子带做带通滤波和CCA候选频率40个、子带5个就是200次滤波加200次CCA纯Python循环撑不住。解决分两级策略先用低阶CCA跑全部候选频率快速排序只保留分数最高的3到5个候选再用FBCCA精判。枕区通道数压缩到4个也能显著降低矩阵运算量。换更快的线性代数后端比如OpenBLAS或MKL同样有效纯NumPy在矩阵规模不大时跑不满CPU。向量化参考信号构造、一次性生成所有频率的模板矩阵能少写不少无效循环。6. 把离线分析改成实时识别的滑动窗口技巧离线分析里你可以拿到完整试次再慢慢算但实时系统里数据是流水一样不断涌进来的。滑动窗口的核心思路是维护一个环形缓冲区每次更新只取最近的一个定长窗口做分类分类结果每滑动一步输出一次。窗口长度决定分类精度滑动步长决定输出频率两者独立设置。class SlidingSSVEP: def __init__(self, srate, window_s, step_s, freqs, n_channels4): self.window_n int(srate * window_s) self.step_n int(srate * step_s) self.freqs freqs # 环形缓冲区预分配 self.buffer np.zeros((n_channels, self.window_n)) def update(self, block): block: (n_channels, n_new_samples)每次送入新的一小段数据 n block.shape[1] # 缓冲区左移腾出右侧空间 self.buffer[:, :-n] self.buffer[:, n:] self.buffer[:, -n:] block # 返回分类结果 return self._classify(self.buffer) def _classify(self, window): # 窗口不满时不输出结果 if len(window[0]) self.window_n: return None epochs mne.EpochsArray(window[None, :, :], info, tmin0) epochs.filter(0.5, 50, fir_designfirwin) return classify_cca(epochs.get_data()[0], self.freqs, srate, self.window_n / srate)这个设计的巧妙之处是分类频率由step_s决定而不是window_s。比如window_s2s、step_s0.2s时每200毫秒输出一次分类结果但每次分类用的还是最近2秒数据。系统会有200毫秒的更新延迟但动作触发可以在分类结果连续三次指向同一目标时再执行这个去抖策略能有效过滤误触发。实时系统里另一个常用技巧是不要每次都对全窗口重算滤波。如果step比window短相邻两次分类窗口重叠了90%大部分计算是重复的。可以把滤波结果也缓存进缓冲区只对新进来的数据块做滤波。这个优化在小规模数据上不明显但窗口长、候选频率多时CPU占用差异很大。我用这套滑动窗口做过一个SSVEP拼写器demo最初window_s1.5s、step_s0.5s识别率稳定但体验迟钝用户每次选择要等小一秒。后来改成window_s1s、step_s0.2s并加了三连击去抖识别率掉了一个点但操作流畅度提升明显。实时系统里永远要在延迟和准确率之间找平衡别把离线指标当作在线体验的必然。希望帮到你。本文还有配套的精品资源点击获取
返回列表