尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

脑电信号无线传输与可视化:基于ESP32和BW16的BCI原型搭建方案

脑电信号无线传输与可视化:基于ESP32和BW16的BCI原型搭建方案 前一阵我在做一台专注力训练的小样机需要把受试者头上的脑电信号实时画出来给他自己看。手里的主力 EEG 模块是 NeuroSky TGAM串口按 ThinkGear 协议往外吐原始脑电波形和注意/冥想值。模块本身没问题但官方那边靠专用蓝牙接收器连电脑想直接甩给手机、大屏或者网页就特别拧巴。后来我索性把链路整个拆开重搭TGAM 模块出来的是 TTL 串口我用一片 BW16RTL8720DN 双频 WiFi 模组把串口数据变成局域网里的 UDP 广播接收端用 ESP32-CYD也就是圈子常说的 Cheap Yellow Display 那块带屏板子收到数据后在本地 TFT 上画滚动波形和数值同时通过内置 Web 服务用 WebSocket 推给浏览器。整条链路跑通后从脑电模块到屏幕、再到网页中间再不用管专用蓝牙和官方 SDK说白了就是造了一条脑电信号的局域网管道”。这个方案适合谁一类是像我一样做 BCI 原型验证的个人开发者一类是做课堂演示或者产品 Demo 的工程师还有一类是想把 EEG 数据从封闭采集设备里解放出来做二次分析的科研党。如果你手里正好有 NeuroSky 系的脑电模块和一块 ESP32-CYD这篇文章可以让你少走至少一周弯路。1. 为什么是 BW16 ESP32-CYD这条链路解决了哪三个痛点1.1 官方蓝牙桥把所有东西都绑死了NeuroSky TGAM 的标准用法是接专用蓝牙适配器PC 端用 ThinkGear Connector 读数据。这套东西稳定是稳定但接收器跟板子是一一绑定的换一台电脑、换一个接收模块都得重新配对。更麻烦的是它只在电脑这个中心上转手机、平板、第二块屏幕想同时看数据基本没戏。我当时的场景是受试者戴着头戴屋里一个大屏想实时显示他的专注力变化同时旁边还要放一台平板做记录。官方蓝牙方案根本撑不起这种多端展示需求。换成 WiFi 流以后同一个局域网里的任何设备都能收到同一份 EEG 数据流这个概念差异是本质性的。1.2 显示端要灵活不能只绑在电脑上做 EEG 可视化最怕被软件的界面绑住。以前验证波形要么开 PC 软件要么在 Arduino 上接块简陋的单色屏想做到受试者自己抬头就能看到的反馈效果特别麻烦。ESP32-CYD 之所以吸引我就是因为它把 ESP32 和 2.8 寸 TFT 做在了一块板子上LovyanGFX 刷起来非常顺画 320x240 分辨率的滚动波形毫无压力而且它还自带 WiFi天然适合做接收端。关键是这块板子本身还能兼当 Web 服务器。你不需要额外接树莓派或者电脑它自己就能把数据推成网页手机浏览器打开一个 IP 就能看到波形。对演示场景来说这个一体机属性太重要了。1.3 成本和功耗都在合理范围内BW16 模块十几块钱双频 WiFi博通的 RTL8720DN 芯片体积小到可以直接塞进头戴壳。做纯串口透传的时候功耗不高电池供电也能撑住。如果你用 ESP8266 做桥也不是不行但 BW16 在串口吞吐和收发稳定性上好一截而且不用额外接电平转换。有人会问为什么不直接让 ESP32-CYD 去连 TGAM原因有两个。第一ESP32-CYD 在系统里的角色是显示终端它既要收 WiFi 数据、又要画屏幕、还要做 Web 推送已经够忙了再给它加一条有线串口等于把系统中心绑死在头戴旁边。第二采集端独立出来后以后换别的脑电模块只改 BW16 那边的接线和解析显示端一行代码都不用动。2. 系统拓扑与通信协议从 TGAM 串口到 UDP 广播再到网页2.1 整条链路长什么样用文字把这个拓扑画出来就是TGAM 脑电模块TTL 串口57600 baud→ BW16 的 UART RXBW16 把串口字节流打包成 UDP 广播发到局域网 8000 端口ESP32-CYD 监听 8000 端口收到后解析 ThinkGear 协议解析出的原始波形和 eSense 值分两路一路画到本地 TFT 屏幕一路通过 WebSocket 推给浏览器有的脑电模块不是 TGAM输出的是厂商自定义串口协议甚至有的是 ADS1299 这类 SPI 接口。没关系只要模块能吐连续字节流这个链路的骨架就可以原样套用改的只是 BW16 端怎么读、ESP32 端怎么解析。2.2 为什么选 UDP 而不是 TCP实时波形这种东西看的是现在正在发生什么。TCP 有确认重传机制一旦网络有点抖动它宁可卡住等重传也不肯丢掉迟到数据。对 EEG 波形显示来说偶尔丢一个 UDP 包屏幕上最多多个毛刺肉眼几乎无感但如果是 TCP 卡住整个画面会像冻住一样体验反而更差。我在局域网 2.4G WiFi 下实测UDP 广播的丢包率基本在 0.5% 以下波形上看不出影响。如果后续要做无损记录正确的做法是另开一条 TCP 通道在 PC 端存档实时显示这条线继续保持 UDP。不要把实时和保真混在一条通道里。2.3 外层封装给原始字节流加上序号和时间标签TGAM 的串口包是变长的而且串口上是连续流一次读出来的数据里可能包含好几个半截包。为了接收端好处理我在 BW16 端给原始数据包了一层很薄的外套2 字节帧头固定 AA 55用来找边界2 字节包序号用来算丢包率2 字节 payload 长度payload 就是原始 TGAM 字节流ESP32 端拿到一包 UDP 数据后先解开外层拿到 payload再喂进 ThinkGear 字节流解析器。这个两层设计看起来多此一举实际调试时帮了大忙包序号一比对就能知道 WiFi 链路到底丢了多少数据帧头也让接收端能随时重新同步。如果你打算以后接多个采集节点这个序号和帧头还可以扩展成节点 ID 和时间戳为多通道同步预留位置。3. BW16 数据桥固件改造最省心的串口透传方案3.1 开发环境和接线方式BW16 在 Arduino 里需要先装 Realtek Ameba 的开发板支持包直接在 开发板管理器 里搜 Ameba 就能找到选对应 RTL8720DN 的设备后烧录。下载程序用的串口和接脑电模块的串口是两回事调试口接电脑时用 115200接 TGAM 的 UART 则单独分配一个 Serial 实例。BG16 的具体引脚要看板子丝印一般有固定的 UART 引脚可以接 Serial1。我把 TGAM 的 TX 接到 BW16 的 RX共地必须接否则串口噪音会很离谱这是很多人第一次接线最容易忽略的。3.2 最小可用的透传代码核心逻辑很简单串口读、攒包、UDP 发。我直接贴一份精简但能跑的版本#include WiFi.h #include WiFiUdp.h const char* ssid EEG_Lab; const char* password yourpassword; const int udpPort 8000; const IPAddress targetIP(255, 255, 255, 255); // 局域网广播 WiFiUDP udp; uint8_t streamBuf[96]; void setup() { Serial1.begin(57600); // TGAM 脑电模块 Serial.begin(115200); // 调试口 WiFi.mode(WIFI_STA); WiFi.begin(ssid, password); while (WiFi.status() ! WL_CONNECTED) { delay(500); } udp.begin(udpPort); } void loop() { static uint8_t txBuf[96]; static uint16_t idx 0; static uint16_t seq 0; while (Serial1.available()) { txBuf[idx] Serial1.read(); if (idx 96) { uint8_t pkt[102]; pkt[0] 0xAA; pkt[1] 0x55; pkt[2] (seq 8) 0xFF; pkt[3] seq 0xFF; pkt[4] 0x00; pkt[5] 0x60; // 96 bytes payload memcpy(pkt[6], txBuf, 96); udp.beginPacket(targetIP, udpPort); udp.write(pkt, 102); udp.endPacket(); idx 0; seq; } } }3.3 为什么攒到 96 字节才发一包UDP 包有固定头部开销如果每来一个字节就发一个包局域网会被小包淹没ESP32 端也会因为中断太多而丢数据。攒到 96 字节再发算下来大概每秒五六十个包既不会明显增加延迟也不会对 WiFi 造成压力。TGAM 的原始脑波是 512Hz 采样每个采样 2 字节再加上一些 eSense 状态信息96 字节的包大概是 8ms 左右的实时数据。从传感器到屏幕的端到端延迟控制在 20ms 以内肉眼完全感觉不到。有一点要提醒BW16 的串口缓冲区不大如果 WiFi 暂时阻塞串口进来的数据没地方放就会丢。所以我实际用的是 2048 字节的环形缓冲Serial1.read 循环里先把字节塞进缓冲区然后另外在 loop 里消费、发包而不是上面这份简版代码的同步写法。两者的区别在流量大的时候非常明显。4. ESP32-CYD 双核工作一边画波形一边推 Web 页面4.1 先确认你的 CYD 屏幕是 ILI9341 还是 ST7789ESP32-CYD 这块板子在社区叫 Cheap Yellow Display具体的屏幕型号并不统一有的批次用 ILI9341有的用 ST7789。最开始我的板子怎么刷都是白屏后来才确定是库默认驱动和实际面板不匹配。我推荐直接用 LovyanGFX 库它对 CYD 这类板子支持得比较好配置里可以明确选择面板型号。如果你已经知道自己的 CYD 是 ILI9341那么面板配置就写 ILI9341SPI 频率可以保守一点设为 26MHz 或者 40MHz。刚点亮的时候可以先用 fillScreen 画红绿蓝三个纯色哪个颜色能正常全屏填充就说明这组配置生效了。4.2 把任务拆分到两个核心上跑ESP32 是双核不利用起来太浪费。我的做法是Core 0专跑 UDP 接收、解包、解析 ThinkGear 协议然后把解析结果扔进一个环形队列Core 1跑 LovyanGFX 绘制和 WebSocket 推送从队列里取数据如果放在同一个循环里屏幕刷新会挡住网络读取UDP 包会疯狂积压。分工以后两个任务各干各的互不打扰。Arduino 环境下用 xTaskCreatePinnedToCore 非常直观TaskHandle_t netTaskHandle; void netTask(void* param) { while (1) { int len udp.parsePacket(); if (len) { udp.read(rxBuf, min(len, sizeof(rxBuf))); parseAndEnqueue(rxBuf, len); } vTaskDelay(1); } } void setup() { xTaskCreatePinnedToCore(netTask, netTask, 4096, NULL, 1, netTaskHandle, 0); // 主循环继续跑绘制 }这里有个小技巧网络任务的优先级不要设太高栈大小给 4096 字节一般就够用但如果你在里面解析较长的报文建议给到 6144 字节避免堆栈溢出导致莫名其妙的复位。4.3 屏幕布局滚动波形加大字数值屏幕的布局我分成三块上面 240 像素左右是波形区画最近 320 个原始采样点连成一条滚动曲线右上角用大号字体显示 Attention 值下面一行显示 Meditation 值左下角有一个小色块表示信号质量绿色表示信号稳定红色表示电极接触不良滚动波形的画法不需要什么高级库函数就是从环形缓冲区里取最近 320 个采样做一次 min/max 归一化后映射到屏幕高度然后用 drawLine 把所有点连起来。我实测 100ms 重绘一次帧率 10fps 左右肉眼看起来非常流畅。如果你想要 30fps 的丝滑感就得开双缓冲但对脑电反馈来说 10fps 已经够了。4.4 网页端AsyncWebServer 加 WebSocket 推送网页显示是整个链路里最有意思的部分。ESP32-CYD 同时跑一个 HTTP 服务浏览器访问它的 IP 就能连上 WebSocket。我在 ESP32 端不直接推原始 TGAM 字节流而是先在本地解析好封装成 JSON 再推出去。每 100ms 推一次JSON 里包含时间戳、最近 32 个原始采样点、Attention 值和 Meditation 值。前端 JS 做的事情很简单WebSocket 收到 JSON 后把采样点数组推入一个长度固定为 320 的环形数组然后画到 Canvas 上。这个方案的好处是浏览器端不涉及任何底层协议解析写起来非常快调试也方便。服务器端用 me-no-dev 的 ESPAsyncWebServer 和 AsyncTCP 库注意版本要配套。WebSocket 客户端数量实测三到五个没问题再多就会出现 WiFi 响应变慢的现象。如果你要做大屏展示控制好同时打开的页面数量。下面给出一个推送 JSON 的示意代码// 每 100ms 在 Core 1 上执行一次 String payload {; payload \t\: String(millis()) ,; payload \a\: String(attention) ,; payload \m\: String(meditation) ,; payload \r\:[; for (int i 0; i 32; i) { payload String(rawSamples[i]); if (i ! 31) payload ,; } payload ]}; ws.textAll(payload);这里 32 个采样点就是 4ms 左右的原始脑波网页端每次收到就把它接在 Canvas 曲线末尾。因为没有把 512Hz 每个点都推出去网页端的渲染压力很小手机开页面也完全不卡。5. 联调时最值得记录的坑波特率、白屏、丢包和内存5.1 TGAM 串口波特率搞错一切白搭TGAM 的典型波特率是 57600但上海外或者二手市场淘来的模块有些出厂被改成了 115200。我建议在把模块接到 BW16 之前先用 USB-TTL 转换器配合串口助手确认一下实际波特率看能不能看到正常的 0xAA 0xAA 包头。如果看到的全是乱码第一件事不是怀疑模块坏了而是换波特率试试。另外 TGAM 上电后的前两三秒会吐一些噪声数据不是有效的脑电帧。BW16 端不用管它直接透传ESP32 端的解析器要有容错能力遇到非法帧就丢掉重新找同步头不要因为几个坏字节就把整个解析器搞崩溃。5.2 CYD 白屏的原因可能不在驱动而在背光我前面提到屏幕型号会导致白屏但还有一种更隐蔽的情况面板配置是对的代码也能刷进去屏幕就是黑的或者白的。这时候去查 CYD 的背光引脚。有的板子背光默认由 3.3V 直接点亮但也有批次用 GPIO 控制代码里没有把背光引脚拉高就是全黑。排查白屏的顺序应该是先看背光是否点亮再用 fillScreen 测纯色最后才去怀疑 SPI 频率和面板型号。我每次都是三个环节按顺序来十分钟之内能定位白屏原因。5.3 UDP 丢包和 BW16 缓冲区溢出是一对连体婴BW16 的 WiFi 芯片如果阻塞在发送上串口端还在不断进来数据缓冲区满了就直接丢弃表现出来就是接收端波形偶尔断一下。解决这个问题有两个关键发包策略改成攒批发送降低发包频率串口环形缓冲加大到 2048 甚至 4096 字节我用包序号做了丢包统计在 2.4G WiFi 局域网内丢包率基本都在 0.5% 以下。如果你测试时发现丢包率明显高于 1%优先检查路由器是不是开了省电模式或者 BW16 和 ESP32 离得太远、信号太弱。WiFi 信号强度对 UDP 丢包的影响比想象中大得多两块板子之间保持两三米直线距离是比较稳的。5.4 ESP32 内存和 WebSocket 推送的权衡ESP32 内存看着够用但跑起 WebServer、WebSocket、TFT 驱动之后空闲堆内存可能只剩几十 KB。我遇到过一个很实际的问题WebSocket 推流频率太高浏览器端反而卡顿因为 JS 解析 JSON 的频率跟不上推送频率。解决方案很简单把推流频率限制在 20Hz 左右。对脑电波形来说20Hz 已经是相当顺滑的视觉体验了而且给 ESP32 和浏览器都留出了余量。如果你的浏览器端还需要同时记录数据就再开一条低频的批量通道不要在同一个 WebSocket 里既要实时又要存档否则两边都会卡。6. 链路留下的扩展接口UDP 直通 PC 与后续源定位最小范数估计6.1 同一个 UDP 流可以不止喂给屏幕这条链路最值钱的地方是把脑电数据从封闭的官方 SDK 里解放出来了。ESP32-CYD 只是第一个消费者同一局域网的 PC 上Python 脚本也可以同时监听同样 8000 端口的 UDP 数据把它写进文件或者直接送进实时分析管线。我当时在 PC 端跑了一个很简单的 Python 脚本用 socket 接收 UDP 后把原始数据按时间戳落盘然后丢给 Python 的 signal 库做 FFT观察 alpha 波段的能量变化。整个过程不需要任何脑电厂商的专用库标准网络编程就够了。如果你想接 Lab Streaming LayerLSL之类的实时流协议同样是在这一层做适配。6.2 最小范数估计源定位的前提多导联数据看到源定位这个词我必须把话说明白TGAM 这种单通道模块做不了脑源定位因为源定位需要头皮表面多个位置的电位分布才能反推出脑内源的位置单通道只有一个测量点信息量不够。所以如果你真的要做 EEG 源定位和最小范数估计至少得用 8 通道或者 32/64 通道的采集设备。这篇文章搭建的无线链路在升级为多导联系统后可以直接套用。比如用 ADS1299 这类多通道采集前端串口吐出多通道采样流BW16 依旧做透传ESP32-CYD 依旧做显示终端只是解析协议和波形绘制的维度从单通道变成多通道。PC 端收到多导联数据后就可以进入标准的 MNE-Python 处理流程。6.3 最小范数估计的基本原理和 MNE 落地路径最小范数估计Minimum Norm EstimateMNE是脑电/脑磁源定位里最常用的逆问题求解方法之一。它假设头皮测得的电位 Y 是由脑内若干源的电流 S 经过体积导体传播后叠加而成用正演模型可以写成 Y L S ε。这里的 L 是导联场矩阵取决于头模型和电极位置。源定位就是已知 Y 和 L反求 S。因为电极数通常远小于源数这是个欠定问题不能直接求逆。MNE 的方法是加一个正则化约束把问题变成求解一个带惩罚项的最小二乘问题S argmin ||Y - L S||² λ² ||S||²。λ 是正则化参数一般和信噪比挂钩MNE 库里可以用信噪比来反推。在 MNE-Python 里落地的大致路径是读取多通道 EEG 原始数据设置电极位置比如 standard_1020 标准蒙太奇做带通滤波一般是 1-40Hz去除直流漂移和工频干扰按事件切分 epoch计算噪声协方差矩阵下载或对齐标准头模型比如 fsaverage计算正演导联场矩阵用 make_inverse_operator 构造逆算子再用 apply_inverse 得到源估计结果伪代码示意一下import mne raw mne.io.read_raw_eeglab(subj.set, preloadTrue) raw.set_montage(standard_1020) raw.filter(1., 40.) events, event_id mne.events_from_annotations(raw) epochs mne.Epochs(raw, events, event_id, tmin-0.2, tmax0.8, baseline(-0.2, 0)) cov mne.compute_covariance(epochs) fwd mne.make_forward_solution( raw.info, transNone, srcfsaverage, bemfsaverage, megFalse, eegTrue ) inv mne.minimum_norm.make_inverse_operator(raw.info, fwd, cov) stc mne.minimum_norm.apply_inverse( epochs.average(), inv, lambda21.0 / 9.0, methodMNE ) stc.plot()注意实际使用时正演模型需要认真配置MRI 头模和电极坐标的配准直接决定源定位结果是否可信这是另一个大坑。但在你的硬件链路已经打通的前提下后端的这些算法问题只是时间和算力投入的问题不会被采集设备堵住。如果你打算沿着这个方向走我的建议是在搭链路的第一天就给 UDP 帧预留好时间戳字段并且把多通道数据按固定顺序排列。等到真上了 32 通道再回头改协议比刚开始多写两行代码麻烦得多。我这次虽然只用单通道 TGAM但包头里的序号字段和长度字段已经留了足够的扩展空间以后升级多导联时显示端和服务端的骨架都能复用。
返回列表