ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

无线脑电采集原型:BW16+ESP32-CYD实现实时波形显示与网页访问

无线脑电采集原型:BW16+ESP32-CYD实现实时波形显示与网页访问 1. 项目概述这条无线脑电链路到底在做什么几个月前我搭了一条无线 EEG 原型链路一端是脑电模块中间是 BW16另一端是 ESP32-CYD 的屏幕和局域网内的网页。这条链路解决了我很长时间的痛点想在人头上采集脑电波形又不想被数据线绑死更不想在采集端堆一堆笨重外设。用一句话概括就是EEG 模块负责采集脑电信号BW16 负责把信号无线发出去ESP32-CYD 负责接收、显示和提供网页访问。说白了就是一套「低成本的无线脑机接口原型」。这套东西适合谁想入门脑机接口的学生、做注意力监测小工具的创客、或者单纯想体验一把「用眼睛盯着屏幕看自己脑波形」的硬件爱好者。它不需要几千块的专业脑电设备也不需要复杂的屏蔽房普通实验室环境就能跑起来。我自己的应用场景是想做一套可穿戴的注意力监测 demo脑袋上不想拖线就选了无线方案。当时选择这条路线的动机很直接脑电信号是连续流式的采集端无法断开连接传统做法是直接把电极引线拉到放大器再进电脑但那样人一动就全是运动伪迹。把采集端做成小节点、用无线把数据搬出去既不影响信号质量又能解放头部活动范围后续想升级成头戴式设备也算留好了接口。更重要的是这一条链路里压缩了完整的嵌入式数据链路知识点传感器读取、协议封装、无线传输、接收端渲染、再到网页可视化每一步都能独立拆出来讲。2. 硬件选型BW16 和 ESP32-CYD 各自顶什么用2.1 BW16专注采集的前端小无线节点BW16 是 Realtek RTL8720DN 方案的开发板双频 Wi-Fi、支持 Bluetooth LE体积比 ESP32 小不少价格也亲民。它在这条链路里扮演的是「前端采集节点」的角色直接从脑电模块读数据做最简单的校验和打包然后通过 Wi-Fi 发出去。选择它而不是直接用 ESP32原因有两个第一采集端要靠近头部尺寸和重量尽量小BW16 的板型比 ESP32 DevKit 苗条很多第二脑电信号本身极其微弱前端主控的电源纹波和射频干扰越少越好BW16 的射频部分和串口读取逻辑相对独立干扰好控制一些。当然BW16 也有缺点Arduino 生态不像 ESP32 那么完善很多库要自己翻源码适配串口只有一个硬件 UART调试时要用 SoftSerial 或者干脆共用串口输出。因此在实际接线时我把 BW16 的日志输出和 EEG 模块的数据读取分开用硬件串口读 EEG用软件模拟串口打印日志这样两条数据流互不干扰。2.2 ESP32-CYD兼任屏幕、接收端和网页服务器的「三合一中控」ESP32-CYD 指的是 ESP32-2432S028R也就是常说的 Cheap Yellow Display集成了 ESP32、2.8 英寸 ILI9341 触摸屏和一点扩展电路二十几块钱一块性价比很高。它在这条链路里是「显示中枢」接收 BW16 无线送来的脑电数据在屏幕上画波形同时启一个 WebSocket 服务让局域网里的手机或电脑打开网页实时查看。选择 CYD 而不是单纯用 ESP32 加独立屏幕纯粹是为了省事。ILI9341 这类屏用 TFT_eSPI 驱动很成熟排线少代码量低。CYD 上还带了触摸芯片 XPT2046虽然这个项目暂时用不到触摸但后续如果想加「点击屏幕记录事件标记」硬件上已经有了基础。2.3 接线表与供电注意事项链路里的接线分两段BW16 到 EEG 模块、ESP32-CYD 到电源与网络无线不用接网线。下面是我实际用的引脚分配连接位置引脚/接口说明脑电模块 TXBW16 A0RX读取脑电原始数据流脑电模块 GNDBW16 GND共地必须同参考脑电模块 VCC3.3VBW16 3V3供电注意电流ESP32-CYD 屏幕板载已连接ILI9341 SPI 接口外部电池ESP32-CYD 5V 脚建议独立供电注意脑电模块务必和 BW16 共地这是无线链路里最容易忽略的坑。共地不稳串口数据全是乱码而且脑电信号本身是微伏级如果前端供电纹波过大波形上会出现明显的周期性干扰。供电上我有一个很深的教训一开始用同一个充电宝同时给 EEG 模块和 CYD 供电波形上立刻出现 12Hz 左右的振荡尾巴查了半天是充电宝的开关管噪声通过地线串了进来。后来改成 EEG 模块用独立的 3.3V LDO 供电、CYD 单独用 5V 适配器干扰立刻小了很多。做生物电相关的东西电源隔离几乎是必须考虑的问题。3. 采集端设计EEG 数据打包与无线发送3.1 脑电模块输出的是什么为什么不能直接塞进 TCP市面上的低成本脑电模块输出一般分两类一类直接输出原始脑电波形采样另一类输出经过芯片处理的专注度、冥想度等参数。我这里用的是能输出原始采样值的模块通常以固定波特率通过 UART 持续吐数据流。要理解为什么不能直接把这个串口数据塞进 Wi-Fi得先看数据形态原始脑电采样率一般是 512Hz每个点可能是 16bit 或 24bit 的小端整数加上模块自己的 FIFO 缓冲和校验位数据流不是严格等间隔的。如果直接用 TCP 或 UDP 一包一包转发接收端很难判断每一包是哪一段时域数据很容易丢失对齐信息。更现实的问题是Wi-Fi 传 UDP 本身就可能丢包接收端必须能根据包序号知道丢了多少点才能决定是补零还是丢弃。我的做法是在 BW16 上做一层轻量封装把连续的脑电数据切成固定大小的数据块每块加上帧头、序列号、采样计数和 CRC 校验再走 UDP 发出去。这样接收端即使丢了几包也能明确知道丢在哪里不至于造成数据错位。3.2 自定义协议帧与 BW16 端实现我定义的帧结构很简单16 字节定长偏移长度含义02帧头 0xAA5522数据包序号42块起始采样计数688 个脑电采样点每个 16bit142对前 14 字节的 CRC16这里把 8 个采样点打包为一个 UDP 包是因为脑电数据量不大没必要用大包小包反而能降低单次丢包的影响。BW16 端核心代码形如void loop() { while (serialEEG.available() 2) { int16_t sample serialEEG.read() | (serialEEG.read() 8); if (fifoIndex 8) { fifo[fifoIndex] sample; } else { sendEEGFrame(fifo, 8); fifoIndex 0; frameSeq; } } }这段代码看似简单但有一个关键点是 BW16 的硬件串口缓冲很小如果主循环稍有延迟就可能丢字节。因此我开了串口空闲中断把脑电数据直接搬进 RingBuffer再在循环里消费 RingBuffer保证高负荷时不会丢字节。3.3 采样率、丢包阈值与调参心得默认 512Hz 的脑电采样意味着每秒钟 BW16 要发约 64 个 UDP 包。在局域网环境这个包量对 BW16 是零压力真正要关注的是实时性每一包从脑电模块到 CYD 屏幕的端到端延迟大概在 10ms 以内这里包括了串口读取时间、打包时间和 Wi-Fi 传输时间。我测试过不同的丢包率对波形的影响低于 1% 的丢包屏幕上基本看不出明显断点只要把缺失点做简单的线性插值即可超过 5%波形会出现毛刺和跳变这时候应该默认丢弃那一段而不是强行补值否则会让频谱分析结果完全不可信。所以我在协议里特意放了「块起始采样计数」接收端可以用它检测连续性和丢失长度。实操心得如果你后续要做脑电特征提取比如算 alpha 波功率千万不要用插值后的数据去做频谱分析。插值会引入低频分量让结果失真。宁可标记出一个时间段内的丢包数在算法里剔除这段数据也不要缝缝补补。还有一个经常被忽略的细节Wi-Fi 的省电模式默认会周期性休眠导致发送延迟抖动。我在 BW16 上关闭了 WLAN 省电模式实测端到端延迟抖动从最高 30ms 降到 5ms 以内。代价是功耗上升对固定供电的 bench demo 无所谓但对以后做电池供电的穿戴设备就要重新取舍。4. 接收端到屏幕与网页ESP32-CYD 的发挥4.1 接收与缓存策略不让波形一卡一卡ESP32-CYD 端要干三件事收 UDP 包、解码、画屏幕。这三件事如果在一个 while 循环里顺序执行一旦屏幕绘制耗时过长UDP 缓冲区就会被填满丢包率直线上升。我的做法是在 CYD 上开一个 UDP 接收任务FreeRTOS Task专门负责收包、解帧和把解出的采样点写入环形缓存主循环只负责从缓存取数据画屏幕。接收任务的优先级设得比主循环高但允许被系统任务抢占。环形缓存的容量设成 4096 个采样点也就是 8 秒的数据这样即使屏幕偶尔卡一下也不会立即丢数据。void udpTask(void *param) { uint8_t buf[16]; while (1) { int len udp.parsePacket(); if (len 16) { udp.read(buf, 16); if (buf[0] 0xAA buf[1] 0x55) { uint16_t seq (buf[2] | (buf[3] 8)); // decode 8 samples and push to ring buffer for (int i 0; i 8; i) { int16_t s (int16_t)(buf[6 i * 2] | (buf[7 i * 2] 8)); ring.push(s); } lastSeq seq; } } vTaskDelay(1); } }这个任务模型是整条链路的骨架。没有它屏幕刷新时 CPU 被 TFT_eSPI 的 SPI 传输占满UDP 包就只能被动丢弃表现就是波形频繁断线。4.2 ILI9341 屏幕波形绘制的关键细节CYD 自带 2.8 寸 ILI9341分辨率为 320x240。画脑电波形最基础的方法是每收到一个新采样点把旧点擦掉、新点画上。但这种方式在 512Hz 采样率下有两个问题一是每秒钟要刷新 512 次SPI 总线根本忙不过来二是 EEG 波形本来就在均值附近快速抖动逐点绘制会非常闪烁。我改成了「分块滚动绘制」把屏幕横向分成 320 个像素列每一列代表一个时间切片缓存里每积累 16 个采样点就计算这一列的平均值或极值画成一个竖线。这样屏幕一屏显示约 320×16 / 512 ≈ 10 秒的波形视觉上非常稳刷新压力也小很多。绘制时用了 TFT_eSPI 的 pushImage 局部刷新只更新当前列之前的 1 像素宽区域实测屏幕刷新率能到 30fps 左右不会出现明显的撕裂感。波形颜色我用绿色画原始信号再用红色画一个滑动平均的包络线方便直观看到 alpha 波的起伏。4.3 网页端实时显示方案屏幕只是本地显示网页端才是真正方便分享的部分。Espressif 官方提供了 WebSocket 库我在 CYD 上挂了一个轻量 HTTP 服务根路径返回一个 HTML 页面页面里的 JavaScript 通过 WebSocket 与 CYD 保持连接实时接收脑电采样值并在 Canvas 上绘制波形。这里有一个选择是让网页端直接从 BW16 拿数据还是从 CYD 中转我选择了从中转原因有两个一是 BW16 端的资源非常紧张再挂一个 WebSocket 服务会影响采集稳定性二是 CYD 上已经有完整的数据通路中转只需要多一次内存拷贝非常轻量。网页端 JavaScript 的核心大约是const ws new WebSocket(ws://192.168.1.100/ws); const buffer []; ws.onmessage (e) { const data new Uint8Array(e.data); const samples new Int16Array(data.buffer, 6, 8); // skip header for (let i 0; i samples.length; i) { buffer.push(samples[i]); } if (buffer.length 32) { drawWave(buffer.splice(0, 32)); } };页面里我用 Canvas 把采样值映射到 0~255 的像素高度再顺着 x 轴画折线。最原始的方案大概 80 行代码就能跑通。网页端虽然在视觉效果上没有屏幕端细腻但它有一个不可替代的价值任何人只要拿到局域网地址打开浏览器就能看到实时波形不需要安装任何驱动或 IDE这对展示 demo 非常有用。5. 踩坑实录从信号噪声到网页卡顿的处理5.1 常见问题速查表现象原因解决办法波形出现周期性 50Hz 干扰供电纹波或地环路独立 LDO 供电共地单点屏幕波形频繁断线UDP 接收任务被屏幕刷新阻塞将 UDP 接收拆成独立任务提高优先级BW16 偶发乱码串口波特率不匹配或共地不稳先测模块实际输出波特率再确认共地网页打开后波形卡顿WebSocket 消息频率过高在服务端聚合采样点降低 ws 发送频率数据丢包率 5%Wi-Fi 干扰或距离过远换 5GHz 频段BW16 支持或靠近路由器画波形时屏幕闪烁全屏刷新太频繁改成分块滚动 pushImage这些坑里大部分不是「理论没学到位」而是现实的嵌入式系统问题优先级设置、供电隔离、不同模块间的时序配合。我第一次调通整条链路时屏幕和网页都有波形但两者的波形差了几百毫秒排查了很久才发现是 CYD 的 WebSocket 服务端默认每收到一包就转发一包而屏幕端是攒了 16 个点才画一次导致时间基准不同。后来统一为「CYD 内部先攒 16 个点再同时输出到屏幕和 WebSocket」两者就完全同步了。5.2 三个最影响效果的隐藏细节第一个是脑电模块的参考电极位置。很多初次尝试的人把参考电极随便夹在耳垂或手腕上波形看起来全是噪声。实际上参考电极的接触阻抗要尽量低最好涂导电膏否则共模干扰会被放大器放大整条链路调得再好也没用。第二个是 Wi-Fi 信道选择。如果整套链路在办公室或实验室跑周围 2.4GHz 设备非常多。我把路由器的 Wi-Fi 信道固定到 9 或 13具体看周边占用情况再配合 BW16 的 5GHz 频段丢包率明显下降。CYD 的 Wi-Fi 只有 2.4GHz所以如果路由器开了双频合一我通常建议把 IoT 设备单独放进「仅 2.4GHz」的 SSID避免频段切换导致的闪断。第三个是 CYD 的电源电流。CYD 板载的 AMS1117 稳压器在屏幕全亮加 Wi-Fi 传输时发热明显长时间工作会烫手。我最终给 CYD 加了散热片并且把屏幕背光从 100% 调到了 80%实测温度降了 10 摄氏度左右。这不是功能性 bug但直接影响长期稳定运行。5.3 从 EEG 到屏幕的「时间对齐」经验脑电数据分析里时间对齐是绕不开的话题。屏幕上显示波形是即时的但网页端因为要走 WebSocket数据到达浏览器时和屏幕端可能不在同一时刻。我在协议帧里放了采样计数接收端就能算出每包的绝对时间戳设第一个采样点的时间为 T0后续每个点的时间是 T0 采样点序号 × (1000/采样率)。如果后续想在这个原型上加源定位或者最小范数估计就必须依赖精确的时间对齐。我的建议是从一开始就在协议里带上采样计数而不是单纯靠「收到包的时刻」判断数据时间。后者在丢包、重传和网络抖动时会产生不可控的时间误差。6. 一点经验总结整条链路跑通后我最深的感受是脑电项目最大的门槛其实不是算法或硬件选型而是把「微伏级模拟信号」和「不可靠的无线信道」可靠地接起来。模拟端要做屏蔽和隔离无线端要做协议封装和丢包容忍显示端又要考虑刷新率和时间同步任何一个环节崩溃都会让最终波形惨不忍睹。我个人在实际操作中的建议是先别急着上网页、上复杂功能。第一次搭建时先保证 BW16 到 CYD 的局域网无线传输是通的用串口监视器直接看波形数据第二步再接到屏幕最后才做网页。分阶段推进你会发现每加一层问题定位的范围就小很多。如果你也想搭类似的无线 EEG 原型链路预算大概在两三百块以内核心器件就是 BW16、ESP32-CYD 和一个脑电模块。后续想扩展的话可以在 CYD 上加 MicroSD 卡记录原始数据或者再引入一个小型算法模块做实时 alpha 波功率计算。这条链路本身不算精致但作为「从传感器到屏幕再到网页」的完整原型它已经把脑电项目里最基础的传输和展示问题都走了一遍剩下的就是往某个方向继续深挖了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进