ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

无线脑电采集系统:BW16+ESP32-CYD实时波形显示实战

无线脑电采集系统:BW16+ESP32-CYD实时波形显示实战 做EEG原型调试的时候最折磨人的往往不是算法而是那根线。电极刚在头皮上贴好你稍微动一下线就牵着整个系统晃眨眼、吞咽、转头波形里全是伪迹。我一直想把前级采集模块和上位机彻底分开让参与实验的人可以戴着电极自由起身走动于是翻了翻手头的元器件最后用 BW16 当贴身采集端、ESP32-CYD 当桌面接收显示端搭了一条无线脑电链路。这条链路解决的核心问题是把脑电模块输出的串口数据流通过 WiFi 搬到一个带屏幕的接收机上再顺带把实时波形同步到浏览器。整个流程可以分成三段脑电模块 → BW16 采集端 → WiFi 无线链路 → ESP32-CYD 屏幕端。CYD 既能直接在屏幕上滚动显示波形又能开一个 WebSocket 服务让电脑网页实时看。下面我把选型逻辑、固件思路、以及实际跑通时踩过的坑全部写出来。1. 先交代一下这条链路的设计初衷1.1 为什么我非要把脑电信号“无线”出去做脑电实验的朋友应该都有过这种体会传统的实验室方案里被试戴着电极帽后面拖着一根 USB 或者串口线连接电脑。坐姿实验还好一旦需要被试坐起来接听电话、站起来走动或者想做体感交互类 demo这根线就成了最大的限制项。线缆晃动会产生位移伪迹而 EEG 信号本身只有微伏级别位移伪迹在幅度上能压过真实的皮层节律。更麻烦的是线缆长度一旦超过两米通讯接口的压降和串扰也随之上升采集端和上位机之间必须加隔离否则共地回路会把 50Hz 工频干扰引进来。无线化之后这些问题的处理方式完全变了。采集端跟身体一起动线缆伪迹直接消失接收端可以放在桌面或者口袋里只负责显示和转发。当然无线化也会引入新的问题比如射频干扰、丢包、供电噪声这些我放到后面第 5 节单独讲。1.2 链路概览采集端 → 无线 → 显示端我这条原型链路的具体构成如下脑电模块单通道 EEG 前端输出标准串口数据包。采集端主控BW16瑞昱 RTL8720DN 双核 WiFi 芯片负责读取脑电模块的串口数据打包后通过 UDP 推送到局域网。接收端主控ESP32-CYDESP32-2432S028R 低成本带屏开发板负责监听 UDP 端口解析数据包把实时波形绘制在 240x320 的 LCD 上。网页端ESP32-CYD 同时开启 WebSocket 服务局域网内电脑浏览器连接后即可看到同样的波形。整条链路的数据流就是脑电模块每秒输出 N 个原始采样点 → BW16 串口读入并组帧 → WiFi UDP 发出 → CYD 收包解帧 → 屏幕滚动绘制 WebSocket 转发。这个结构的好处是各端职责单纯出了问题可以单独定位不会一锅粥。2. 选型对比BW16 和 ESP32-CYD 各自吃了哪一块分工2.1 BW16 适合做贴身采集端的三个原因BW16 这颗模块用的瑞昱 RTL8720DN 芯片双核架构Cortex-M4F 主核 Cortex-M0 低功耗核支持 802.11b/g/n 和 BLE 5.0关键是它便宜、体积小、功耗可控。我选择它做贴身采集端有三个具体理由第一待机功耗和射频功耗比 ESP32 更省。做可穿戴原型时供电通常来自 3.7V 锂电池BW16 在 WiFi 连接待机状态下的实测电流比 ESP32 低不少这意味着同样的电池容量能多撑一段时间。第二它的串口资源够用。脑电模块用一路串口接收数据还能留一路串口打调试日志不会出现引脚捉襟见肘的情况。第三BW16 的 Arduino 生态相对成熟Ameba 开发环境里面已经有现成的 WiFi 库和串口库不必自己啃 RTOS SDK。当然 BW16 也有明显的短板外设资源偏少模拟采样能力弱不适合直接接模拟脑电前端做 ADC所以我的分工明确——BW16 只做“读串口 推网络”不做任何信号处理。2.2 ESP32-CYD 当接收/显示端性价比在哪ESP32-CYD 是网上俗称“小黄屏”的板子核心是 ESP32-WROOM-32板载一块 2.4 寸 240x320 的 LCD插上 USB 就能烧录调试。它做接收显示端太合适了屏幕驱动简单TFT_eSPI 库直接支持接线固定不需要自己按照具体 LCD 型号逐个调。算力足够ESP32 双核 240MHz跑 UDP 接收、波形绘制、WebSocket 转发完全没压力。WiFi 收发稳定因为它和 BW16 都在同一个局域网里信道选择灵活很容易避开互相干扰。其实也可以用 ESP32 直接当采集端把脑电模块接到 ESP32 的 UART再开 WiFi 推流整个链路还能少一个模块。但我在实际测试中发现ESP32 的射频开启时电源纹波偏大对微伏级 EEG 前端的影响比 BW16 更明显所以我把采集端和接收端分开中间用 WiFi 拉一条“无线隔离带”反而让前级信号干净不少。2.3 链路磨合时要注意的“窝里斗”同频干扰与信道划分两个 WiFi 设备放同一张桌子上最烦的就是它们自己先打起来。BW16 负责往 AP 推数据ESP32-CYD 得同时连同一个 AP 才能收到两台设备实际上在同一个信道里跑。如果 AP 开的是自动信道选择某个瞬间跳到拥堵信道UDP 丢包率会立刻上升。我的解决办法是把路由器或者 AP 的 WiFi 信道固定在 1、6、11 中某个相对干净的信道并且让 BW16 与 ESP32-CYD 都在 2.4G 频段工作不要开蓝牙功能减少带内干扰。实测下来固定信道后丢包率从千分之几的有效波动降到了基本不可感知。3. 脑电模块接入从型号选择到串口数据包3.1 我用的脑电模块及数据包格式我这条链路上用的是一款常见的单通道脑电模块输出 ThinkGear 系列的串口数据包。数据包以0xAA 0xAA开头后续是长度、载荷和校验。重点关注的载荷有两种0x80是原始脑电波形值0x02是信号质量0x04是注意力数值这几个字段足够原型阶段用了。帧结构大致这样0xAA 0xAA 长度 0x80 原始值两字节 ... 0x02 信号质量 ... 校验字节每个原始采样点的数值范围通常落在 -32768 到 32767 之间实际有效动态范围远没这么大脑电信号的有效幅度一般只有几十到几百微伏取决于前端增益。模块内部已经做了滤波和放大所以我在固件里不需要做数字滤波只需要做“取数”和“转发”。如果你用的是 ADS1299 或者 OpenBCI 那种多通道方案数据包格式会有较大差异而且往往需要通过 SPI 读取寄存器、配置采样率。那种情况下建议你在 BW16 前面加一颗 MCU 专门做寄存器和 SPI 时序BW16 继续干串口收包的活避免主核被 SPI 占用影响 WiFi 响应。3.2 电平、共地、抗干扰前端最容易翻车的地方脑电模块和 BW16 之间如果直接连 UART最容易忽略三件事电平匹配很多脑电模块虽然是 3.3V 逻辑但个别版本可能用 5V MCU。BW16 的引脚不能容忍 5V一旦接错直接烧引脚。我特意用万用表量了模块 TX 输出的高电平确认在 3.3V 左右才敢连到 BW16 的 RX。共地串口通讯要求两端共地。脑电模块的 GND 必须与 BW16 的 GND 连在一起否则会出现随机乱码而且信号参考点悬空波形漂移特别离谱。串口波特率某些脑电模块默认 57600有些是 115200还有极少数是 9600。如果模块和固件波特率不一致数据包在帧头检测阶段就会全部丢弃。安装连接时我还习惯在脑电模块的电源引脚并联一个 10uF 电解电容和一个 0.1uF 陶瓷电容目的很简单滤掉电源线上的低频波动和高频毛刺。这个动作对微伏级模拟前端的效果非常明显不加电容和加电容的波形差距肉眼可辨。3.3 把原始波形“喂”给 BW16 的初始化流程BW16 端的串口初始化流程我建议这样写使能串口例如 UART0 或 UART1设置波特率和 8N1。把接收缓冲区分成独立的环形缓冲避免串口中断里做耗时的格式化。在主循环里改用“按字节排队 状态机”解析帧头不要等一帧到齐再解析因为 WiFi 推流要求低延迟。解析出来的原始波形、信号质量、注意力等字段填进结构体交给网络任务处理。BW16 的 Arduino 环境里串口写法是void setup() { Serial.begin(57600); // 接脑电模块 WiFi.begin(SSID, PASS); udp.begin(LOCAL_PORT); } void loop() { while (Serial.available()) { uint8_t b Serial.read(); feedToParser(b); // 按状态机解析脑电包 maybeSendPacket(); // 攒够一组就发 UDP } }实际开发时可以开成两个线程一个线程专门读串口解析一个线程专门跑 WiFi 发送这样串口高密度数据不会把网络任务饿死。BW16 的双核正好可以这么用M4F 跑协议栈M0 跑串口采集互不拖累。4. BW16 采集端固件实战串口读入、WiFi 推流4.1 串口资源与引脚分配BW16 模块引出的 UART 数量是够用的但有一个容易踩的细节很多 BW16 开发板自身烧录使用的串口引脚和用户可用的串口引脚是复用的。你插上 USB 调试时调试串口占用了某个 UART如果再拿这个 UART 接脑电模块会出现程序烧录正常、运行后数据却错乱的现象。我的建议是脑电模块必须接在开发板上明确标注的“用户串口”引脚上烧录则用 USB 转串口芯片的调试口。如果你不确定引脚映射去查对应板卡的 schematic或者干脆第一天先写一个 loopback 测试把 TX 和 RX 短接看看串口能发能收再接入脑电模块。这一步能省掉很多“为什么收不到数据”的排查时间。4.2 UDP 还是 TCP原型链路我选 UDP 的理由脑电波形是高频连续数据流延迟敏感丢一两个点不影响整体波形但我无法接受重传导致的排队延迟和波形“突跳”。所以在原型阶段我选 UDP理由有三脑电采样率通常只有 128Hz 或者 256Hz单个数据包很小UDP 报文正好合适。TCP 的 Nagle 算法和重传机制会引入不可预测的延迟实时性大打折扣。UDP 不需要维护连接状态BW16 断线重连后直接恢复发送逻辑简单。当然 UDP 也有代价丢包不会自动补。我的补偿办法是给每个数据帧编一个序号接收端通过序号检测丢包插值或者标记异常区间具体做法写在 5.3 节。4.3 自定帧格式与序号机制为了让接收端只靠网络层就能完成“断帧”和“对时”我自定义了一个网络帧格式[0xEB 0x90] [数据长度 1字节] [设备ID 1字节] [包序号 2字节] [数据负载 N字节] [校验和 1字节]0xEB 0x90是帧头接收端用它同步。包序号是关键每次发送前自增。数据负载里面直接放脑电模块的原始字节这样接收端拿到负载后再按脑电帧格式解析一次即可省去两次组包的重复代码。在 BW16 端组包发送的核心代码大致如下void sendEegFrame(uint8_t* payload, uint8_t len) { uint8_t frame[32]; frame[0] 0xEB; frame[1] 0x90; frame[2] len 4; // 3到13字节是负载与序号 frame[3] deviceId; frame[4] seq 8; frame[5] seq 0xFF; memcpy(frame[6], payload, len); frame[6 len] checksum(frame, 6 len); udp.beginPacket(ip, REMOTE_PORT); udp.write(frame, 6 len 1); udp.endPacket(); seq; }这个帧格式看着简单但解决了接收端最关键的问题无论网络怎么抖动只要收到了完整帧就能按序号排序如果序号出现跳变说明中间丢了包屏幕上就可以标记一个空窗。4.4 断线重连与低功耗的取舍可穿戴设备不能遇一次 WiFi 掉线就得断电重启。我在 BW16 里写了一个简单的状态机WiFi 断开时自动重连重连成功后 UDP 端口重新绑定继续发送当前序号。if (WiFi.status() ! WL_CONNECTED) { WiFi.reconnect(); delay(500); return; }但注意delay(500)会让串口缓冲区在 500ms 内积压大量脑电数据导致波形的实时性瞬间崩掉。更好的做法是重连期间不阻塞串口采集把串口数据继续往环形缓冲里写等网络恢复后立即按最新序号补发。这其实就是典型的“流量整形”只是放在 MCU 里手动实现了。功耗方面如果你只是做桌面 demoBW16 可以保持长开。要做真正可穿戴就得让 BW16 在脑电模块无数据时进入低功耗睡眠周期性唤醒发送保活包。我目前原型没有开睡眠但已经预留了电源管理引脚后续迭代会处理。5. ESP32-CYD 接收端屏幕绘制实时脑电波形5.1 点亮 CYD 屏幕需要做的 TFT_eSPI 配置ESP32-CYD 的屏幕是 SPI 接口Arduino 里最常用的库是 TFT_eSPI。安装后必须先修改User_Setup_Select.h里对应的开发板配置选择 CYD 定义文件否则颜色发暗、背光不亮、触摸不响应。以我手头这块 ESP32-2432S028R 为例TFT_eSPI 库已经内置了User_Setup_ESP32_2432S028R.h这个配置直接在 User_Setup_Select.h 里启用它即可。配置要点包括屏幕驱动 ILI9341分辨率 240x320。引脚定义LCD_CS15、LCD_DC2、LCD_RST4、LCD_BL21、SPI_SCLK14、SPI_MOSI13。背光引脚 21 需要设为高电平否则屏幕黑得像是没通电。点亮后的下一步要处理坐标旋转。脑电波形通常是横向滚动我一般设置屏幕旋转为横向setRotation(1)这样显示区变成 320x240有更多横向空间画时间轴。5.2 双缓冲 定时器刷新把 FPS 稳定住直接在主循环里画波形最直观的问题是波形会闪。TFT_eSPI 提供pushImage方法我采用的是双缓冲思路先在内存里维护一个 320x240 的白底画布或灰色网格画布。新数据到达时只在画布上增量绘制最新一列像素旧列滚动左移。绘制完成后一次性pushImage到 LCD避免逐像素操作的闪烁。注意ESP32 的 RAM 只有 328KB240x320 的 RGB565 画布需要 240x320x2153600 字节也就是 150KB压力有点大。我的做法是把画布降为 320x120只画半屏波形剩余区域留给数值文字显示。这样既能保证刷新率又不会导致内存不足重启。刷新节奏也值得聊聊。我不用死循环刷而是用定时器设定固定 30FPS 刷新。脑电采样率如果是 256Hz那么每帧大约要显示 8~9 个采样点。如果刷新率太高波形反而显得跳刷新太低实时感就没了。30FPS 在演示时是平衡点。5.3 用 UDP 包序号来插值丢包不破图UDP 丢包最直观的表现是波形突然断一截。如果断 1~2 个采样点还好断 10 个点波形上就是一条竖线很难看。我的接收端逻辑是收到一帧读取包序号seq。如果seq 上一个 seq 1正常绘制。如果seq 上一个 seq 1判定丢包计算丢失点数用线性插值补齐。如果丢失点数超过阈值比如 50 点说明 WiFi 链路已经出了严重问题此时宁可画成空白区间也不要用插值“脑补”否则会给人造成数据没断的错觉。插值的实现不复杂但要注意脑电信号是带生理节律的插值补出的波形不代表真实脑活动。我的标记办法是插值区域用浅灰色绘制真实数据用蓝色让看的人一眼就能分辨哪些是真的、哪些是网络修补出来的。5.4 屏幕之外串口同步输出与 SD 卡写入屏幕只是展示实验场景下还得留原始数据。我在 CYD 端同时做了两件事通过 USB 串口输出原始解析数据方便 PC 接串口记录。如果插了 TF 卡就把解析后的原始波形和包序号一起写进 CSV 文件作为数据留存。TF 卡的 SD 库在 ESP32 上可能会占用额外的 SPI 引脚而 CYD 的 LCD 也占了 SPI。这时候要留意分配冲突最好是让 SD 卡使用 VSPI 之外的 SPI 总线或者干脆只在需要录数据时才挂载 TF 卡。原型阶段我基本只在串口输出因为录数据的需求通常是在实验台上而不是在演示现场。6. 网页端怎么接CYD 当 Web 服务器与 PC 网页浏览两条路线6.1 路线 AESP32-CYD 开 WebSocket 服务浏览器直连这是最直接的方式。CYD 本身有 WiFi我可以让它在 STA 模式下连到同一个路由器然后开一个 WebSocket Server监听8080端口。电脑浏览器访问http://CYD的IP:8080时页面通过 WebSocket 连接 CYD实时接收波形数据并用 Canvas 绘制。CYD 上运行 WebSocket Server 的代码可以用 Arduino 的WebSocketsServer库。核心处理逻辑在webSocketEvent回调里收到客户端连接后把后续解析的脑电数据广播给所有已连接 WebSocket 客户端。因为脑电数据量不大每帧最多十来字节CYD 完全可以胜任几百毫秒间隔的推送不会卡。一个小提醒浏览器端的 Canvas 也不适合每收到一个点就重绘一次太低效。比较稳的做法是浏览器端维护一个环形数组每 33ms 触发一次定时重绘一次绘制近 1 秒的数据窗口。6.2 路线 BBW16 推送局域网电脑网页做聚合展示如果你不想让 CYD 既当网络接收端又当 Web 服务器可以换一种做法BW16 直接把 UDP 数据发给 PC或者发给局域网里的一个简单 Web 服务CYD 只是作为一个独立屏幕端同样收听同一路 UDP。这样 CYD 和 PC 网页都能显示相同的波形CYD 不需要承担 WebSocket 的转发任务CPU 占用更低。我在实测中更推荐路线 B因为它是“广播式”的多个设备可以同时监听同一个 UDP 端口CYD 看波形PC 录数据还能在旁边挂一台显示器做大屏展示互不干扰。路线 A 适合没有电脑的情况下做单机展示演示者端着 CYD 就能当临时仪器。6.3 网页端帧同步的小坑整数与浮点的字节序网页端最容易踩的坑反而不是 WiFi而是数据编码。脑电原始值常见的是两字节有符号整数而 JavaScript 的ArrayBuffer默认是大端字节序后端发来的是小端序如果不做转换波形就是一串乱跳的噪声。我在 BW16 端故意把脑电原始值拆成高字节和低字节按“高在前、低在后”的顺序发送网页端这样解析const view new DataView(payload.buffer, payload.byteOffset, payload.byteLength); let raw view.getInt16(0, false); // false 表示大端与发送端对应在 CYD 端也要用同样的解析顺序否则屏幕波形和网页波形会不一致。这种细节属于典型的“协议约定问题”写代码时定好一个规则所有端都遵守后面基本不会出错。7. 实测中真的会踩的坑五条现场经验7.1 电源噪声无线模块反而加剧了 EEG 毛刺一开始我以为无线之后电源会更干净实际上相反。BW16 和 CYD 的 WiFi 射频一开板载稳压器的输出纹波立刻增大脑电前端如果直接从同一个电源轨取电波形上会出现明显的周期毛刺频率大致对应 WiFi 的信标间隔。解决方法是把脑电模块的电源和前级放大电源彻底分开脑电模块单独用一块 LDO 或低噪声稳压芯片供电。BW16 的电源轨和脑电模块电源轨之间串一个磁珠。尽量让脑电模块的模拟地单点接回电池负极。这个改动我最开始没在意后来用示波器看 3.3V 电源纹波从 20mV 涨到了 60mV波形上全是尖刺排查了半天才定位到是共电源引起的。分开供电后尖刺立刻消失。7.2 WiFi 天线离电极太近射频干扰压过有效信号这是困扰我最久的一个坑。电极贴在前额BW16 天线大概在 10 厘米外本来以为距离够远但开机后脑电波形直接“罢工”满屏都是高频振荡。后来我把天线挪到离电极 30 厘米以上波形立刻恢复。原因不复杂2.4GHz 射频虽然不在 EEG 的通带内但射频信号会在电极线和人体回路里产生解调效应形成带内噪声。实测中 PIF 天线和 PCB 天线的干扰程度不同但都不能贴太近。如果你也得把模块戴在头上建议天线位置尽量远离电极走线垂直放置而不是平行贴头皮。电极线和模块连线使用短的屏蔽线。不用 WiFi 时让 BW16 进入射频睡眠能少一截干扰。7.3 屏幕刷新闪烁别用全屏清屏指令CYD 屏幕分辨率不高但 TFT_eSPI 的fillScreen()清空全屏很耗时动画刷新率会掉到个位数而且视觉上闪。双缓冲之后依然有闪是因为内存画布那 150KB 完全送屏需要时间。我的改进是增量绘制不整个清空画布而是把画布内容左移一列只填充右侧新增的波形列保留背景网格。这样每次刷新只需要推送一列像素刷新率从 8FPS 直接提升到 30FPS。屏幕上的数值文字也一样不要每帧重画整个数字区域判断数字有变化才更新否则数字剧烈跳动时屏幕会闪烁。7.4 波特率与丢包流控没设置导致的“每秒少几十包”BW16 与脑电模块的串口连接稳定后我发现一个诡异现象波形大部分时间正常但每隔几秒会少几十个采样点。查来查去不是网络丢包而是串口中断处理被 WiFi 任务抢占环形缓冲溢出丢数据。解决思路是分层处理串口接收用中断模式中断里只往环形缓冲写字节绝不做解析。解析和组包放在主循环或低优先级线程。适当调大环形缓冲至少能容纳一个 WiFi 信标周期内的数据量。我用的是 4096 字节环形缓冲单通道 256Hz 采样率下足够应付实测不再出现周期性缺数。7.5 协议字段与前端解析对齐问题最后这个坑比较隐蔽。脑电模块的数据包里有多个字段不同版本的模块固件可能某些字段不存在。我刚开始按固定偏移量解析结果信号质量值经常读成 200一直以为是前端质量差后来发现是字段错位。改进方式是使用健壮的解析器遇到0xAA 0xAA后扫描所有载荷段根据段类型分别解析不依赖固定偏移量。这样无论模块固件字段增删正确字段总能被提取出来。这个经验同样适用于网页端解析逻辑一旦写死换个模块批次就翻车。8. 从原型到可以戴出去的路后续迭代我打算做什么8.1 体积与功耗压缩BW16 的睡眠模式与轻量供电现在的原型链路能跑通但体积其实不小脑电模块一块板子、BW16 一块板子、电池一块还有一堆杜邦线。下一步我准备做一块定制 PCB把脑电前端、BW16、电源管理集成在一起目标是戴在耳后或者额头上不突兀。功耗方面BW16 的睡眠模式是关键。理想状态是没有有效脑电数据时BW16 进入 Sleep脑电模块进入低功耗待机检测到信号后唤醒WiFi 快速重连并推流。这个逻辑在 Ameba SDK 里有示例但需要牺牲一些推送延迟我正在衡量。8.2 多通道同步与后续算法落地单通道只能做最简单的节律展示往深了走必然要上多通道。多通道脑电不仅需要同步采样还要保证多个通道的数据在同一时刻被打包、同一时刻被无线发出。BW16 串口带宽有限8 通道 256Hz 的原始数据量大约是每秒 8x256x24096 字节算上协议开销其实还能撑但我倾向于在采集端先做轻量特征提取比如分频段能量只推送压缩后的特征把原始数据留给实验室离线分析。算法层面的下一步我打算把在线预处理放到 ESP32-CYD 端比如实时计算频带能量、检测眨眼伪迹然后在屏幕上直接展示。等这套流程稳定之后再迁移到网页端结合开源工具做拓扑图、脑电源定位与最小范数估计之类的离线分析。原型阶段先跑通后面每一步都有得玩。如果你也想复刻这条链路我给的建议是先别急着一次跑通所有端按“脑电模块 → 串口助手 → BW16 → UDP 调试工具 → CYD 收包”这个顺序逐级验证。每一段都确认无误再连下一段能少折腾两晚上。我的实测体验是最难的不是代码而是那些信号完整性问题和射频干扰这两类问题靠看代码是看不出来的必须动手量、动手试。祝你也能顺利把脑电信号从有线世界里解放出来。
返回列表