ARTICLE DETAIL

资讯详情

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

低成本无线脑电采集系统:从BW16串口解析到ESP32-CYD实时可视化

低成本无线脑电采集系统:从BW16串口解析到ESP32-CYD实时可视化 去年年底我在折腾无线脑电时身边朋友的第一反应都是“这玩意儿不是实验室里用有线放大器才能干的事吗自己搭一条无线链路是不是有点飘”等我把 BW16 和 ESP32-CYD 这套组合真正跑通之后发现原型验证远没有想象中那么复杂——一个消费级脑电模块输出串口数据BW16 负责把数据捞起来走 WiFi 发出去另一端 ESP32-CYD 既当接收端又当屏幕显示顺便还挂了个网页。整套东西从模块到屏幕再到浏览器延迟能压到 100ms 以内足够做实时波形预览、注意力/冥想度展示以及后续的脑电数据处理实验。这篇文章就是完整记录这条无线 EEG 链路怎么从零搭起来硬件怎么选、接线有哪些坑、固件里怎么解析脑电数据、通信协议怎么设计、屏幕和网页怎么同步刷新。无论你是刚接触脑电传感器的硬件爱好者还是想用低成本方式做生物电可视化实验的开发者这条链路都值得参考。我不写教科书式的原理堆砌只讲实操中验证过的方案和踩过的坑。1. 先理清楚这条链路要解决什么以及为什么这么拆1.1 脑电模块和它输出的原始数据我用的脑电模块是一块基于 TGAM 方案的消费级模块这种模块在很多 DIY 脑电项目里出现过。它内部集成了放大、滤波和 ADC通过干电极或者湿电极采集头皮电位差最终在串口上输出两种信息一种是原始脑电波形样本另一种是经过芯片内部算法处理后的频谱能量数据比如 δ、θ、α、β、γ 五个频段的功率以及“注意力”和“冥想度”这种参考指标。原始脑电数据的采样率通常是 512Hz每个样本是 16 位有符号整数。模块默认的串口波特率是 57600bps输出的是字节流数据帧有固定的包头和校验位。比如常见的帧格式是AA AA 04 80 02 ...这样一组十六进制字节其中AA AA是同步头后面的长度位、载荷、校验和都有严格定义。如果你像我一样直接用串口助手去读能看到一堆看似杂乱但实则规律的数字。脑电信号本身是微伏级别的微弱信号模块已经做了初步处理但后续如果要显示平滑波形最好还是自己再加一道带通滤波和工频陷波。我为什么特意把“原始数据”和“算法指标”分开说因为这直接决定了无线链路的带宽需求。原始波形每秒 512 个样本、每个样本 2 字节也就是大约 1KB/s再加上频段能量和注意力等指标总量也不大。所以普通 WiFi 完全能扛住不需要蓝牙低功耗那种压缩方案。这也是我选择 WiFi 而不是 BLE 的主要原因——BLE 虽然更省电但要想低延迟传原始波形实际可用吞吐量经常不够而且协议栈处理起来更绕。1.2 无线传输端选型为什么是 BW16穿戴端要戴在头上体积和功耗都不能太大。我选了 BW16它是 Realtek RTL8720DN 芯片的模组板载 WiFi 和 BLE同时兼容 Arduino 开发方式。BW16 的封装很小大概指甲盖大小供电 3.3V逻辑电平也是 3.3V跟绝大多数脑电模块都能直接对接。选它而不是 ESP32-C3原因有三个第一BW16 的价格更低单模组十块上下做原型不心疼第二BW16 的主频虽然只有 200MHz 左右但跑 UART 解析和 WiFi 发送绰绰有余实际用下来 CPU 占用不高第三它有两个完整的 UART 外设其中一个用于烧录和调试另一个正好留给脑电模块。我用的就是 UART1引脚为PA18TX和PA19RX跟脑电模块的TXD和RXD交叉连接即可。当然BW16 也有坑。它刚上手时开发环境配置比较繁琐Arduino 内核需要从第三方 Gitee 仓库添加而且部分库和 ESP32 不通用。但好在官方文档和社区资料已经比较全照着做基本能跑通。1.3 显示/网关端选型为什么是 ESP32-CYD接收端我选了一块 ESP32-CYD全称是 ESP32-2432S028R板子上集成了一块 2.4 寸 TFT 触摸屏、一个 microSD 卡槽、一个 RGB LED以及一个 USB 转串口芯片。因为价格便宜、板子又是黄色国外社区直接叫它 Cheap Yellow Display也就是 CYD。这块板子在国内也很常见很多做桌面小屏幕的教程都会用到它。选 CYD 的核心原因很简单它自带屏幕省去了一堆七零八落的显示模块接线同时它又是一颗标准 ESP32支持 WiFi、TCP、WebSocket 等全套网络协议。这样它既能作为 TCP 服务端接收 BW16 发来的脑电数据又能在本地屏幕上画波形还能挂一个 HTTP/WebSocket 服务让局域网里的手机、电脑打开浏览器直接看实时数据。一个设备完成“接收、显示、转发”三件事非常契合原型开发。CYD 的屏幕驱动是 ILI9341触摸是 XPT2046Arduino 社区有现成的库不需要自己啃时序。需要注意的是CYD 的屏幕刷新比较慢如果直接在主循环里drawPixel画波形会明显闪烁甚至撕裂。我后面会讲怎么用双缓冲和局部刷新解决。1.4 整体数据流架构这条链路的数据流可以这样看佩戴端的脑电模块通过电极采集信号在模块内部完成放大、滤波、ADC 和一部分频谱计算通过 UART 串口把原始波形和指标以字节流的形式输出。穿戴端的 BW16 通过 UART1 接收这些字节流解析出完整数据帧重新封装成自定义的二进制数据包再通过 WiFi 以 TCP 客户端身份发到接收端。接收端的 ESP32-CYD 开启 TCP Server监听指定端口收到数据包后解析一边刷新本地屏幕一边通过 web 服务器把数据推送给浏览器。浏览器端收到 WebSocket 推送的 JSON 或二进制数据后在 Canvas 上绘制实时波形、频段能量条和注意力/冥想度曲线。这套架构的关键在于“无线”带来的物理隔离电极到脑电模块的线很短不会拖一根长线到显示屏那边干扰和活动受限的问题都解决了。同时只要把 BW16 换成其他 WiFi 模组或者把 ESP32-CYD 换成树莓派、PC链路依然成立只是接收端需要适配不同的协议。2. 硬件接线、供电与信号处理最容易翻车的环节2.1 脑电模块与 BW16 串口接线在搭链路之前先把穿戴端的硬件连接搞清楚。脑电模块通常引出这几个引脚VCC、GND、TXD、RXD以及电极接口。有的模块还有SET、IOS之类的配置脚一般悬空或者接特定电平具体看模块说明书。我用的模块默认输出原始数据和频段能量不需要额外配置。接线方式如下脑电模块VCC→ BW163V3两者都支持 3.3V 供电注意一定不要接 5V脑电模块GND→ BW16GND脑电模块TXD→ BW16 的PA18UART1 TX也就是 BW16 接收脑电数据的 RX 脚脑电模块RXD→ BW16 的PA19UART1 RXBW16 发送配置给脑电模块的 TX 脚千万注意 TX/RX 是交叉的模块的 TX 要接 BW16 的 RX模块的 RX 接 BW16 的 TX。很多新手在这里习惯性同名连接结果怎么都读不到数据。BW16 的 UART1 引脚不是固定只有一组但我实测用PA18/PA19最稳因为在 Arduino 环境里可以直接通过Serial1访问不用手动映射外设引脚。还有一点脑电模块的输出电平有的标称 3.3V有的是 3.3V TTL 逻辑BW16 的引脚耐压也按 3.3V 设计所以直连没问题。但如果你拿到的模块是 5V 供电且输出 5V 电平则必须加电平转换电路否则会烧坏 BW16 的引脚。保险起见先用万用表量一下模块 TX 引脚在空闲时的电压再决定要不要加转换。2.2 供电与共地不要小瞧噪声脑电信号极其微弱对电源纹波非常敏感。我踩过一个大坑用同一个充电宝给 BW16 和脑电模块供电时波形上总会叠加一个明显的 100Hz 左右周期性噪声后来排查发现是充电宝升压电路产生的纹波通过电源线串扰进了脑电采集通道。解决办法是尽量把穿戴端的供电和接收端完全隔离。穿戴端我最终用了两节 3.7V 锂电池串联再经稳压到 3.3V 给 BW16 供电脑电模块则由同一路 3.3V 供电。注意所有设备必须共地也就是电源负极连在一起。如果 BW16 和脑电模块各用一个小电池不把 GND 连接起来串口信号完全是浮空的数据读取肯定会出问题。另外电极线和模块到 BW16 的跳线要尽量短而且不要和电源线绑在一起。如果条件允许把脑电模块的电极输入线用屏蔽线连接屏蔽层接系统地。我用的是干电极帽佩戴前还需要用导电膏或者生理盐水润湿电极否则头皮接触阻抗太高信号会非常差波形基本是一条直线带毛刺。这里强烈建议先用模块自带的测试信号功能验证硬件通路是否正常再开始采集真实脑电。2.3 电极佩戴与信号质量的初步验证就算接线完全正确脑电信号也可能因为电极接触不良而出不来。我常用的验证流程是先用示波器或者万用表测量模块TXD引脚看看有没有明显的串口波形然后接上 BW16写一个最简单的串口读取程序把收到的十六进制打印到串口监视器上。如果看到AA AA开头的数据帧说明模块在工作如果没有先检查波特率是不是 57600再检查电极是否紧密贴合皮肤。佩戴位置建议参考国际 10-20 系统的额叶区域比如 Fp1 和 Fp2 附近。干电极帽的电极一般放在前额参考电极REF夹在耳垂或耳夹上。头皮表面有油脂用酒精棉擦拭一下再做测量信号幅度会明显提高。我实测下来安静状态下闭眼 α 波会比较明显地出现睁眼时 α 波抑制这也是检验链路是否正常的一个快速标准。3. 固件与通信协议让每一帧脑电数据都有归宿3.1 BW16 端串口数据解析与 WiFi 发送BW16 的固件逻辑其实很简单初始化串口、连接 WiFi、连接 ESP32-CYD 的 TCP Server然后循环读取脑电数据。为了避免在主循环里做复杂的阻塞操作我用了非阻塞的读取方式把串口收到的一字节一字节塞进缓冲区然后按帧头匹配解析。脑电模块的数据帧格式虽然各家略有差异但基本都遵循“帧头 数据 校验”的结构。以我手上的模块为例典型的原始数据帧长 32 字节第一字节是0xAA第二字节是0xAA之后是 1 字节长度、若干数据载荷和 1 字节校验和。在用 BW16 解析时特征也比较简单无非是状态机发现0xAA后判断下一个字节是否也是0xAA如果是则开始接收后续长度字段然后根据数据长度接收完整个帧最后校验和判断有效。解析完成之后我会把原始波形样本、五个频段能量值、注意力和冥想度打到一个结构体里然后封装成自定义二进制包。我不用 JSON 的原因很简单BW16 的内存不算宽裕构造 JSON 字符串会引入不必要的开销而且二进制包解析速度更快适合实时性要求高的场景。当然二进制包可读性差所以我会在接收端把数据再转成 JSON 给网页用。BW16 作为 TCP 客户端连接 ESP32-CYD 的 IP 和端口。我固定使用端口8899避免和常见服务冲突。连接建立后BW16 的loop里调用client.print发送二进制流。如果连接断开则自动重连。为了防止 TCP 粘包我在每个二进制包末尾额外加了两个字节的帧尾标志0x0D0A接收端按这个标志切包。这里给出 BW16 端核心循环的逻辑示意void loop() { /*********** 串口解析与打包 ***********/ while (Serial1.available()) { uint8_t b Serial1.read(); parseEegByte(b); // 状态机解析脑电帧 } if (eegFrameComplete) { buildPacket(eegFrame); // 打包成自定义二进制帧 sendPacket(); // 通过 WiFi TCP 发送 eegFrameComplete false; } /*********** WiFi 维护 ***********/ if (!tcpClient.connected()) { reconnectTcp(); } }实际测试中BW16 从收到脑电串口帧到发出 WiFi 数据包延迟基本在几毫秒内。因为 WiFi 本身会引入 10-30ms 的延迟整条链路从电极到接收端大约 20-50ms还在原型可接受范围内。3.2 ESP32-CYD 端TCP 接收与 UI 显示ESP32-CYD 这边的任务复杂不少。它要创建 TCP Server 并接收 BW16 的数据同时还要刷新屏幕和提供 Web 服务。为了不让屏幕刷新阻塞 TCP 接收我在 CYD 上把不同任务分配到不同核心上核心 0 跑 TCP Server 和 WebSocket 服务核心 1 跑 UI 刷新。ESP32 是双核芯片FreeRTOS 支持指定任务运行核心像下面这样xTaskCreatePinnedToCore(serverTask, server, 8192, NULL, 1, NULL, 0); xTaskCreatePinnedToCore(uiTask, ui, 8192, NULL, 1, NULL, 1);TCP Server 收到二进制包后先按帧头校验再解析各个字段把结果放到一个全局结构体里。UI 任务以大约 30 FPS 的频率读取这个结构体然后绘制波形和数值。为了保证读取过程不产生竞争我用的volatile标志加临界区或者在 UI 任务里先关中断再拷贝数据。实际原型阶段用个简单互斥锁就够了。屏幕绘制我强烈建议使用“离屏帧缓冲”。CYD 屏幕是 320x240 的RGB565 格式全屏缓冲大约 3202402 150KBESP32 的内部 RAM 一般放得下但加上 TCP 缓冲和 Web 资源就有点紧。所以我只对波形区域做局部缓冲例如画波形图时先在一个 320x80 的缓冲区里更新背景和曲线然后整体 push 到屏幕这样能避免明显闪烁。显示内容上我的屏幕分三个区域顶部波形区域显示最近 2 秒的原始脑电波形中部五个频段能量条用不同颜色标识 δ、θ、α、β、γ底部注意力/冥想度数值以及当前 WiFi 已连接标志。如果愿意还可以在屏幕角落显示一个小的 FFT 频谱图但这会占用比较多 CPUCYD 的 ILI9341 刷新速度有限我建议原型阶段先不做直接在网页端看频谱。3.3 通信帧格式设计我为什么没用 JSON 裸奔我设计的二进制包结构非常简单字节偏移长度内容02帧头0xA5 0x5A21数据长度固定 3632原始样本值int16大端52δ 能量uint1672θ 能量uint1692α 能量uint16112β 能量uint16132γ 能量uint16151注意力0-100161冥想度0-100172包序号uint16用于判断丢包192帧尾0x0D 0x0A总计 21 字节每次发送一帧。因为 512Hz 采样率下每约 2ms 一个样本如果每个样本都发一帧WiFi 包数量太多了。我改成每 10 个原始样本才发一包也就是约 20ms 发一包包里有一个原始样本加上频段指标但包里的波形只取最近一个样本所以屏幕上显示的是降采样后的波形。如果你需要显示连续 512Hz 波形可以把一批 10 个样本都放进去但要注意分包传输。实际上原型阶段 10ms 一包足够了UI 上看起来波形很平滑。为什么不用 JSON因为在 20ms 间隔、21 字节的包如果变成 JSON大概会是{raw:123,delta:45,...}这样 50 字节以上数据量翻一倍而且 BW16 和 ESP32 都要做 JSON 解析内存和 CPU 开销都不小。等数据到达网页端时CYD 的 Web 服务再把二进制包转成 JSON浏览器那边就不存在性能问题了。3.4 网页端实时可视化的实现网页端接收数据的方式我选了 WebSocket而不是轮询 HTTP。原因很简单轮询只能做到每秒几次脑电波形移动会一顿一顿而且 CYD 本身也要处理 UI不能频繁响应 HTTP 请求。WebSocket 则可以从 CYD 主动推送数据浏览器端被动接收延迟低且服务端开销小。CYD 上我用WebSocketsServer库同时监听81端口。当有 WebSocket 客户端连上来后服务端任务每收到一个 TCP 二进制包就把它转换成 JSON 字符串然后通过webSocket.sendTXT推给浏览器。代码类似String payload String({\raw\:) eegData.raw ,\delta\: eegData.delta ,...}; webSocket.broadcastTXT(payload);浏览器端就是一个纯 HTML 页面用原生 JavaScript 的WebSocketAPI 连接ws://CYD_IP:81/收到消息后用JSON.parse解析然后绘制到 Canvas 上。波形图我用动画帧循环从环形缓冲区读取最近 512 个数据点绘制折线图频段能量条用矩形填充注意力/冥想度直接用数字更新。这段网页代码没有用任何重型框架一个 HTML 文件就搞定。如果要把 CYD 上的网页文件存下来可以直接把 HTML 压缩成数组烧进 SPIFFS或者让 CYD 直接返回一段内嵌字符串。内嵌字符串方便改但代码文件会很长用 SPIFFS 更整洁。CYD 有 SD 卡槽把 HTML 放进 SD 卡再SD.open读取也可以不过注意 CYD 的 SD 卡是 SPI 接口有些卡初始化会慢需要等待几秒。4. 调试实录我在这条链路上踩过的坑4.1 串口波特率不一致导致乱码脑电模块出厂波特率是 57600但有的模块允许通过SET脚高低电平修改波特率。我第一次用的时候没留心默认初始化 BW16 的Serial1为 9600结果收到的数据全是乱码而且状态机一直匹配不到帧头。后来用逻辑分析仪测了模块 TX 引脚的实际波特率才意识到问题。判断串口波特率是否正确的技巧在串口监视器里看收到的字节如果是AA AA这种有规律的数据说明波特率基本正确如果满屏都是??或者乱跳先检查电平是否接反再检查波特率。我建议直接用 57600 初始化Serial1.begin(57600);另外脑电模块上电后可能需要几百毫秒完成初始化BW16 上电后不应立即发送任何串口数据。如果 BW16 在上电瞬间把某个引脚拉到高电平可能干扰模块启动。所以我在 BW16 代码里延迟 1 秒再开始读取。4.2 WiFi 断流和粘包处理BW16 的 WiFi 连接偶尔会断开尤其是在路由器重启或模组长时间运行后。我做的处理是在主循环里检查client.connected()断开就清空 TCP 对象并重连。重连时先WiFi.disconnect()再重新WiFi.begin避免驱动状态异常。粘包是另一个问题。当 BW16 发送频率较高时ESP32-CYD 端可能会一次性收到两三个包的数据。我一开始没按帧尾切包导致波形偶尔出现毛刺。后来接收端改用“串口缓冲区 帧尾匹配”的方式把所有收到的数据追加到缓冲区然后循环查找0x0D 0x0A找到一帧就解析一帧剩余数据留在缓冲区继续等下包。这样即使发生粘包也能正确切分。4.3 屏幕刷新撕裂与页面卡顿CYD 的 ILI9341 如果每次更新一部分波形就会出现明显的“残影”和闪烁因为像素点是一点一点画上去的。我改成双缓冲之后效果立竿见影。所谓双缓冲其实是先在一个内存数组里绘制完整的波形区域然后用pushImage一次性把整块区域推到屏幕。CYD 的TFT_eSPI库提供了pushImage用法如下tft.pushImage(x, y, w, h, framebuffer);对于 320x80 的波形区域RGB565 缓冲大小 51.2KB在 ESP32 的 520KB SRAM 里完全放得下。我实测刷新率从原来的 15FPS 提升到 30FPS 左右肉眼基本看不到闪烁。网页端卡顿的常见原因不是数据量而是浏览器绘制太频繁。我会在浏览器端维护一个简单的节流只有收到新包时才绘制而且如果当前帧正在执行requestAnimationFrame就跳过本次绘制否则 Canvas 重绘可能堆积。4.4 电源噪声干扰 EEG 信号这是整个项目里最折腾的问题。起初我用同一路 USB 5V 给 BW16 和脑电模块供电结果波形上全是周期性干扰FFT 之后能看到明显的 50Hz 和 100Hz 尖峰。我试过加 100nF 电容、加磁珠效果都不理想最后改成锂电池组经 AMS1117 稳压到 3.3V噪声才明显下降。除了电源隔离我还把脑电模块的电极线换成双绞屏蔽线屏蔽层接系统地。注意屏蔽层只能在一点接地如果两头都接反而会形成地环路引入更大噪声。干电极使用前用酒精擦拭皮肤并在电极上涂少量导电膏信号质量能有质的提升。如果你的环境中 50Hz 工频干扰特别强可以在软件里加一个 50Hz 数字陷波器不过原型阶段我优先通过硬件做好屏蔽。5. 从原型到下一步还能怎么玩5.1 数据存储与离线分析CYD 上有 SD 卡槽这是白送的功能。你可以在 TCP Server 接收数据的同时把原始二进制帧追加写入 SD 卡里的日志文件。注意 SD 卡写入时间较长可能需要几十毫秒如果每次都同步写卡会拖慢接收。我的做法是先在内存里攒一段数据比如 1 秒的数据量然后一次性追加写卡。或者干脆把 SD 卡接到 BW16 穿戴端直接在穿戴端存储数据但这样就需要额外的模块和代码。有了离线数据你就可以做更多分析计算不同频段的功率谱密度、做眨眼伪迹检测、训练简单的专注度分类模型。这些在 EEG 数据处理里都很经典而且不依赖云服务。5.2 接入 Python 生态做脑电源定位热词里提到的“最小范数估计”是一种脑电源定位算法用来根据头皮电极的电位分布反推大脑内部源的位置。我之前也简单探索过让 ESP32-CYD 把数据转发到局域网内的 PCPC 端用 Python 的 mne 库接收实时数据流然后做源定位。这条链路比屏幕显示要更长但基础就是我现在搭的无线传输部分。CYD 可以扮演一个数据网关把二进制包解码后通过 UDP 转发给 Python 进程之后 Python 侧就能用mne的RawArray构建实时流再接mne.minimum_norm做源估计。当然单通道的脑电模块做源定位精度极其有限通常需要多通道设备。但这不妨碍你把链路先跑通理解数据流和算法接口。真正需要多通道时只需要把raw字段换成多通道数组其他部分几乎不用改。5.3 低成本生物电采集的更多可能性这套“穿戴端采集 WiFi 传输 接收端显示/网页”的架构不只是 EEG 能用。换一个传感器模块就能变成无线心率、无线肌电、无线皮电采集。BW16 的 UART 接口很通用接收端 CYD 的显示逻辑也很通用改一改数据解析部分就行。比如我之前试过用光电心率传感器模块把raw换成 PPG 数据就能实时显示心率波形代码改动量很小。最后再分享一个小技巧这套链路里最容易被忽略的是“接地策略”。在穿戴端和接收端之间没有物理有线连接的情况下两端只靠 WiFi 无线通信看似完全隔离但如果你给 BW16 和 ESP32-CYD 分别接不同的电源又没有让它们的 GND 通过任何方式连在一起那么 TCP 通信通常还是能正常工作因为 WiFi 射频本身不依赖地线。但一旦你通过 USB 给接收端供电同时穿戴端用电池这时候如果电脑的 USB 地线有干扰可能会通过 WiFi 模块的射频地引起一些轻微噪声实际影响不大。不过当你用 USB 调试串口时记得把 BW16 的 GND 和 USB 转串口工具的 GND 连在一起否则串口工具无法识别信号。如果你也想复刻这个项目我建议从一开始就把“穿戴端电池供电”和“接收端独立供电”分开并且不要图方便用 USB 充电头给穿戴端供电因为充电头的纹波真的会毁掉脑电波形。先用几节干电池或者锂电池撑起 BW16 和脑电模块验证数据能稳定显示之后再考虑加充电管理和更紧凑的电源设计。这条链路跑通之后你会对脑电数据流、嵌入式网络通信和实时可视化都有非常直观的理解也会发现所谓“原型”并不需要昂贵仪器——一块几十块的脑电模块两颗十来块的单片机就能搭出一套有意思的无线 EEG 装置。
返回列表