十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

零知派——基于STM32F103RBT6实现GSR皮肤电导情绪波动实时监测仪

零知派——基于STM32F103RBT6实现GSR皮肤电导情绪波动实时监测仪 情绪这东西看不见摸不着但身体会诚实地留下痕迹——紧张、焦虑、突然被吓一跳的瞬间手心会不自觉地冒汗。这背后是交感神经系统在起作用汗腺活动增强皮肤电导率跟着升高。GSRGalvanic Skin Response皮肤电反应传感器测的就是这个变化目录一、系统接线部分1.1 硬件清单1.2 接线方案表1.3 具体接线图1.4 接线实物图二、安装与使用部分三、代码讲解部分3.1 非阻塞状态机3.2 双时间尺度滤波3.3 按键中断及消抖3.4 局部刷新缓存3.5 非阻塞任务调度四、项目结果演示4.1 操作流程4.2 视频演示五、GSR测量原理深度解析5.1 传感器工作原理5.2 ADC量化与阈值设计六、常见问题解答FAQQ1触发得特别频繁/特别迟钝Q2为什么校准的时候要求保持手指静止不动项目概述基于皮肤活动原理以零知派标准板主控STM32F103RBT6为核心搭建了一套集实时采集、数字滤波、情绪波动检测、彩色显示、串口数据记录于一体的GSR情绪波动监测仪电极开路未佩戴自动识别提示接触恢复后无缝续接。支持按键手动重新校准基线适应环境/接触状态变化。项目难点问题描述在裸机环境下同时完成ADC采样、显示刷新、串口发送、LED闪烁和按键响应如果用delay()阻塞方式任何一个任务都会卡住其他所有任务解决方案采用状态机时间戳的非阻塞架构。每个任务独立记录上次执行的时间戳每次loop()循环中检查millis() - lastTime interval满足条件才执行一、系统接线部分1.1 硬件清单序号硬件名称型号/规格数量1主控板零知派标准板STM32F103RBT612TFT彩屏ST7789 240×240 SPI接口13GSR传感器皮肤电导传感器模块模拟输出14LED5mm LED15按键6×6mm轻触开关26杜邦线公对公/公对母若干若干1.2 接线方案表以下所有引脚定义均来自项目Config.h请严格对照代码定义接线不得随意更改模块Config.h 宏定义对应零知派引脚接线说明GSR传感器GSR_PINA0传感器 SIG → A0模拟输入GSR传感器—GND / VCCGND→GNDVCC按模块要求接3.3V或5VST7789屏幕TFT_CSD10ST7789直插零知派标准板无需单独接线ST7789屏幕TFT_DCD2ST7789屏幕TFT_RSTD4ST7789屏幕硬件SPI固定引脚D13/D11ST7789屏幕—GND / VCC提示LEDALERT_LED_PINA5外接限流电阻后接LED正极负极接GND翻页按键BTN_NEXT_PIND0另一端接GND内部上拉代码里INPUT_PULLUP功能按键BTN_ACT_PIND1另一端接GND内部上拉两个按键均使用INPUT_PULLUP模式无需外接上拉电阻按下时引脚被拉低1.3 具体接线图GSR传感器的电极请紧贴食指和中指同一只手指套收缩力度适中GSR模块使用5V供电按键的另一端连接到GND1.4 接线实物图通电前请再次确认所有接线无误特别是VCC和GND不要接反二、安装与使用部分2.1 开源平台-输入情绪波动实时监测仪并搜索-代码下载自动打开2.2 连接-验证-上传2.3 调试-串口监视器三、代码讲解部分项目代码结构本项目代码分为四大模块Config.h参数全局配置、GSRMonitor.h/.cpp采集与检测、DisplayManager.h/.cpp显示驱动、ButtonManager.h按键3.1 非阻塞状态机void GSRMonitor::update() { uint16_t raw analogRead(GSR_PIN); lastRaw raw; if (!checkContact(raw)) { // 开路不喂入滤波/基线/历史避免脏数据污染函数立即返回 // 不阻塞、不等待——接触恢复后自然从下一次采样继续。 systemState STATE_NO_CONTACT; return; } systemState STATE_RUNNING; filteredValue filterRaw(raw); // 波动展示窗口内冻结基线避免基线被这次波动本身拉走 if (!alertActive) { baseline (filteredValue - baseline) * BASELINE_EMA_ALPHA; } float delta filteredValue - baseline; unsigned long now millis(); // ── 情绪波动SCR非阻塞检测 ────────────────────────────────────────────── bool inCooldown hasEvent (now - lastEventTime EVENT_COOLDOWN_MS); bool eventNow false; if (fabs(delta) EVENT_THRESHOLD !inCooldown) { eventNow true; hasEvent true; lastEventTime now; eventCount; alertActive true; alertStartTime now; state GSR_ALERT; DEBUG_PRINT([GSR] 情绪波动检测! delta); DEBUG_PRINTLN(delta); } // ── 历史入列按DISPLAY_REFRESH_MS周期而非每次采样都入列 ───────────────── pendingEventMark pendingEventMark || eventNow; if (pushDivCounter (DISPLAY_REFRESH_MS / SAMPLE_INTERVAL)) { pushDivCounter 0; pushHistory(filteredValue, pendingEventMark); pendingEventMark false; } // ── 提醒窗口非阻塞自动结束到时间就恢复CALM采样从未停过 ────────────────── if (alertActive (now - alertStartTime ALERT_DISPLAY_MS)) { alertActive false; state GSR_CALM; } }|filtered - baseline| EVENT_THRESHOLD——只有当快速信号偏离慢速基线超过阈值时才触发EVENT_COOLDOWN_MS2秒内不重复触发同一次波动防止尖峰抖动导致重复计数记录alertStartTime由loop()每次检查是否超时而不是用delay()卡死3.2 双时间尺度滤波这是整个项目的算法核心实现了信号调理和情绪波动检测的完整链路float GSRMonitor::filterRaw(uint16_t newRaw) { rawBuffer[filterIndex] newRaw; filterIndex (filterIndex 1) % FILTER_WINDOW_SIZE; long sum 0; for (int i 0; i FILTER_WINDOW_SIZE; i) sum rawBuffer[i]; return (float)sum / FILTER_WINDOW_SIZE; } GSRData GSRMonitor::getData() { GSRData d; d.raw lastRaw; d.filtered filteredValue; d.baseline baseline; d.delta filteredValue - baseline; d.eventCount eventCount; d.lastEventAgo hasEvent ? (millis() - lastEventTime) / 1000 : (unsigned long)-1; d.runtime (millis() - startTime) / 1000; d.state state; d.alertActive alertActive; d.contactOk (systemState ! STATE_NO_CONTACT); return d; }标准的有限窗口滑动平均FILTER_WINDOW_SIZE88个最近采样值的算术平均负责压掉高频噪声3.3 按键中断及消抖// 在中断服务函数里调用只做时间戳记录和消抖判断不做耗时操作 void onInterrupt() { uint32_t now millis(); bool pressed (digitalRead(_pin) LOW); // INPUT_PULLUP按下为低 if (now - _lastEdge BTN_DEBOUNCE_MS) { _lastEdge now; return; // 消抖窗口内的抖动边沿忽略 } _lastEdge now; if (pressed) { _pressStart now; _isPressed true; } else { // 没有对应的按下边沿就先收到释放是噪声干扰不是真实按键动作 // 直接丢弃——之前这里缺了这层判断噪声会拿_pressStart0算出一个 // 接近开机到现在的巨大时长被误判成超长按触发休眠 if (!_isPressed) return; _isPressed false; uint32_t duration now - _pressStart; if (duration BTN_LONGPRESS_MS) _pendingLong true; else _pendingShort true; } }知识点说明volatile关键字中断和主循环共享的变量必须用volatile修饰防止编译器优化掉“看似没变化”的读取CHANGE中断模式同时捕获上升沿和下降沿一次按键按下松开产生两次中断分别记录按下时刻和松开时刻3.4 局部刷新缓存显示驱动采用了缓存对比精准清除的策略// ─── 数值更新idx: 0主数值, 1基线, 2偏离────────────────────────────── void DisplayManager::updateCard(uint8_t idx, const String val, uint16_t color) { int x, y, w, h; if (idx 0) { x 16; y MAIN_VAL_Y; w 208; h MAIN_VAL_H; _tft.setTextSize(4); } else if (idx 1) { x 16; y ROW_Y 12; w ROW_DIV_X - 16 - 8; h ROW_H - 16; _tft.setTextSize(2); } else { x ROW_DIV_X 12; y ROW_Y 12; w 240 - 16 - (ROW_DIV_X 12); h ROW_H - 16; _tft.setTextSize(2); } _tft.fillRect(x, y, w, h, cBg()); _tft.setFont(NULL); _tft.setTextColor(color); int16_t bx, by; uint16_t tw, th; _tft.getTextBounds(val.c_str(), 0, 0, bx, by, tw, th); int cx x (w - (int)tw) / 2; int cy y (h - (int)th) / 2 - by; if (cx x) cx x; _tft.setCursor(cx, cy); _tft.print(val); } // ─── 数据页动态刷新 ─────────────────────────────────────────────────────────── void DisplayManager::drawDataPage(const GSRSnapshot d) { String sv fmtVal(d.filtered); String sb fmtVal(d.baseline); String sd fmtDelta(d.delta); uint16_t vColor (d.state GSR_ALERT) ? cWarn() : cValue(); if (_cacheVal ! sv) { updateCard(0, sv, vColor); _cacheVal sv; } if (_cacheBase ! sb) { updateCard(1, sb, cValue()); _cacheBase sb; } if (_cacheDelta ! sd) { updateCard(2, sd, d.delta 0 ? cWarn() : cOk()); _cacheDelta sd; } updateState(d.state); updateFoot(fmtFoot(d.eventCount, d.lastEventAgo)); }脏检查Dirty Checking 只有数值真正变化时才发起SPI传输大幅降低总线占用精准清除fillRect的区域与文字区域严格对齐不会误擦旁边的显示内容分层绘制静态底图顶栏、分隔线、标签只画一次动态数值单独刷新3.5 非阻塞任务调度loop()是整个系统的“心脏”采用时间片轮转的非阻塞架构void loop() { uint32_t now millis(); // ── 休眠任意键唤醒 ────────────────────────────────────────── if (appState APP_SLEEP) { bool wake btnNext.consumeShort() || btnNext.consumeLong() || btnAct.consumeShort() || btnAct.consumeLong(); if (wake) { // 清空事件、唤醒屏幕、重绘 appState stateBeforeSleep; display.wakeScreen(); display.redrawCurrentPage(gContactOk); } return; } // ── 按键事件处理非阻塞───────────────────────────────────── if (btnNext.consumeShort()) { display.togglePage(gContactOk); } if (btnAct.consumeLong()) { /* 进入睡眠 */ } if (btnAct.consumeShort()) { /* 手动校准 */ } // ── GSR 采样固定周期 20ms───────────────────────────────── if (now - tSample SAMPLE_INTERVAL) { tSample now; Gsr.update(); // 内部不含任何delay() } // ── LED 闪烁非阻塞───────────────────────────────────────── if (Gsr.isAlertActive()) { if (now - tLedBlink ALERT_BLINK_MS) { tLedBlink now; ledOn !ledOn; digitalWrite(ALERT_LED_PIN, ledOn ? HIGH : LOW); } } // ── 显示刷新固定周期 100ms───────────────────────────────── if (now - tDisplay DISPLAY_REFRESH_MS) { tDisplay now; // 组装快照 → 传给DisplayManager绘制 display.drawDynamic(snap); } // ── 串口数据流固定周期 100ms─────────────────────────────── if (now - tPlx PLX_STREAM_MS) { tPlx now; Serial.print(DATA,TIME,...); } }调度时间线示意所有任务交错执行、互不阻塞这是裸机环境下实现“多任务并行”的核心设计模式系统流程图四、项目结果演示4.1 操作流程①上电启动开始约3秒的基线校准TFT显示GSR MONITOR顶栏若电极佩戴良好进入数据页实时显示皮肤电导数值、基线、偏离量Δ底部显示状态CALM/ALERT和累计波动次数若电极未接触屏幕内容区变红提示NO CONTACT顶栏保留接触恢复后自动无缝切回正常显示②按键操作BTN_NEXT短按数据页与趋势图页之间切换趋势图页展示最近约12秒的皮电曲线和基线参考虚线BTN_ACT短按触发手动重新校准屏幕显示RECALIBRATING BASELINE...约3秒后恢复正常显示基线更新为新的读数。BTN_ACT长按屏幕进入ST7789芯片级睡眠黑屏但非填黑功耗更低任意按键可唤醒唤醒后自动恢复之前所在的页面③触发情绪波动轻轻掐一下自己或者做一个引起紧张的小动作顶栏连接圆点会在正常色/红色之间闪烁约3秒同时LED同步闪烁状态行文字变为ALERT底部波动计数1整个过程屏幕数值不会卡顿因为采样一直在正常进行④上位机联动串口按115200波特率连接电脑打开Excel配合PLX-DAQ插件实时看到皮肤电导曲线随电极接触/情绪波动变化4.2 视频演示零知派STM32F103RBT6 GSR情绪波动监测仪本视频演示零知派标准板STM32F103RBT6 ST7789彩屏搭建的GSR皮肤电导实时监测仪完整操作流程开机基线校准、数据页/趋势图页切换、模拟情绪波动触发提醒、手动重新校准基线、屏幕休眠与唤醒以及串口联动Excel实时画图全过程演示重点展示情绪波动提醒展示期间采样不中断这一非阻塞设计的实际效果五、GSR测量原理深度解析5.1 传感器工作原理皮肤电导测量基于一个简单的生理事实汗液是电解质溶液汗腺分泌越多皮肤电导率越高人体汗腺受交感神经系统Sympathetic Nervous System控制。当人经历情绪刺激紧张、恐惧、兴奋时交感神经被激活汗腺分泌增加皮肤表面的电导率随之上升。这个变化可以在1~3秒内发生形成可测量的电信号尖峰GSR信号的两种成分GSR信号在时域上可分解为两个分量成分全称特性时间尺度对应生理SCLSkin Conductance Level缓慢变化的“基调”数十秒~数分钟整体情绪唤醒水平、体温、出汗量SCRSkin Conductance Response快速变化的“尖峰”1~3秒特定刺激引发的瞬时情绪反应本项目中的慢速EMA基线追踪的就是SCL而快速滑动平均捕获的就是SCR外源式exosomatic测量法向皮肤施加一个恒定且人体感觉不到的微小电压通常0.5V然后测量流过皮肤的电流。根据欧姆定律电导 G 电流 I / 电压 V由于电压恒定电流与电导成正比。传感器将这个电流信号转换为模拟电压输出由MCU的ADC读取5.2 ADC量化与阈值设计零知派标准板搭载的STM32F103RBT6内置12位ADC分辨率达12bit量化公式为ADC值 (模拟电压 / 参考电压) × 4096以3.3V参考电压为例最小可分辨电压为303.3V / 4096 ≈ 0.806mV/LSB本项目Config.h中定义EVENT_THRESHOLD 60对应模拟电压变化为60 × 0.806mV ≈ 48.4mV这个阈值经过了实际测试校准——既足够灵敏到检测SCR尖峰又不会因噪声而频繁误触发双滤波器如果只用原始ADC值直接判断原始值波动 真实SCR 基线漂移 噪声基线漂移如手部温度变化导致的缓慢出汗量变化可能达到数十甚至上百个ADC计数值远大于SCR尖峰。直接用原始值会导致大量误报项目的双滤波器方案原始ADC → 滑动平均(FIR) → filtered → 与baseline比较 → 检测SCR↑EMA(IIR) ← baseline (SCL)滑动平均去除高频噪声工频、接触抖动EMA基线只跟随SCL的缓慢变化不受SCR尖峰影响差值比较|filtered - baseline|本质上是提取了SCR成分排除了SCL和噪声六、常见问题解答FAQQ1触发得特别频繁/特别迟钝AEVENT_THRESHOLD阈值本来就是因人而异的经验值和电极接触松紧、个人皮肤出汗量都有关系建议先通过串口打印delta的实际波动范围再据此调整EVENT_THRESHOLDQ2为什么校准的时候要求保持手指静止不动A校准阶段是在给baseline找一个初始参考点如果这时候手指移动或电极松动采到的平均值本身就是错的后续所有delta计算都会跟着偏本项目由零知实验室深圳市在芯间科技有限公司开发维护仅供个人非商业性学习使用
返回列表