OpenBCI-实战五:脑电数据可视化仪表板

OpenBCI-实战五:脑电数据可视化仪表板

文章目录

引言

在前几篇实战文章中,我们学习了注意力监测、情绪识别和脑波控制等应用。现在,让我们来构建一个综合性的脑电数据可视化仪表板

数据可视化是BCI系统中非常重要的一环,它可以帮助用户直观地理解脑电信号的特征和变化趋势。一个好的可视化仪表板应该能够实时显示多种数据类型,包括原始波形、频谱分析、统计数据等。


项目概述

仪表板功能

  1. 实时波形显示:显示各通道的原始EEG波形
  2. 频谱分析:实时FFT频谱图
  3. 频段功率:各频段功率条形图
  4. 注意力/情绪指标:实时数值和趋势图
  5. 数据统计:均值、标准差、峰值等统计数据
  6. 历史回放:支持历史数据的回放和分析

技术栈

组件 技术 说明
后端 Python 数据处理和业务逻辑
GUI PyQt5 + QtCharts 专业图表组件
实时绘图 Matplotlib 灵活的绘图库
数据存储 SQLite 轻量级数据库
EEG采集 BrainFlow 统一BCI API

系统架构

#mermaid-svg-ak8OTAqBtoEgAANv{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ak8OTAqBtoEgAANv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ak8OTAqBtoEgAANv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ak8OTAqBtoEgAANv .error-icon{fill:#552222;}#mermaid-svg-ak8OTAqBtoEgAANv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ak8OTAqBtoEgAANv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ak8OTAqBtoEgAANv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ak8OTAqBtoEgAANv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ak8OTAqBtoEgAANv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ak8OTAqBtoEgAANv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ak8OTAqBtoEgAANv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ak8OTAqBtoEgAANv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ak8OTAqBtoEgAANv .marker.cross{stroke:#333333;}#mermaid-svg-ak8OTAqBtoEgAANv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ak8OTAqBtoEgAANv p{margin:0;}#mermaid-svg-ak8OTAqBtoEgAANv .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ak8OTAqBtoEgAANv .cluster-label text{fill:#333;}#mermaid-svg-ak8OTAqBtoEgAANv .cluster-label span{color:#333;}#mermaid-svg-ak8OTAqBtoEgAANv .cluster-label span p{background-color:transparent;}#mermaid-svg-ak8OTAqBtoEgAANv .label text,#mermaid-svg-ak8OTAqBtoEgAANv span{fill:#333;color:#333;}#mermaid-svg-ak8OTAqBtoEgAANv .node rect,#mermaid-svg-ak8OTAqBtoEgAANv .node circle,#mermaid-svg-ak8OTAqBtoEgAANv .node ellipse,#mermaid-svg-ak8OTAqBtoEgAANv .node polygon,#mermaid-svg-ak8OTAqBtoEgAANv .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ak8OTAqBtoEgAANv .rough-node .label text,#mermaid-svg-ak8OTAqBtoEgAANv .node .label text,#mermaid-svg-ak8OTAqBtoEgAANv .image-shape .label,#mermaid-svg-ak8OTAqBtoEgAANv .icon-shape .label{text-anchor:middle;}#mermaid-svg-ak8OTAqBtoEgAANv .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ak8OTAqBtoEgAANv .rough-node .label,#mermaid-svg-ak8OTAqBtoEgAANv .node .label,#mermaid-svg-ak8OTAqBtoEgAANv .image-shape .label,#mermaid-svg-ak8OTAqBtoEgAANv .icon-shape .label{text-align:center;}#mermaid-svg-ak8OTAqBtoEgAANv .node.clickable{cursor:pointer;}#mermaid-svg-ak8OTAqBtoEgAANv .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ak8OTAqBtoEgAANv .arrowheadPath{fill:#333333;}#mermaid-svg-ak8OTAqBtoEgAANv .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ak8OTAqBtoEgAANv .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ak8OTAqBtoEgAANv .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ak8OTAqBtoEgAANv .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ak8OTAqBtoEgAANv .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ak8OTAqBtoEgAANv .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ak8OTAqBtoEgAANv .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ak8OTAqBtoEgAANv .cluster text{fill:#333;}#mermaid-svg-ak8OTAqBtoEgAANv .cluster span{color:#333;}#mermaid-svg-ak8OTAqBtoEgAANv div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ak8OTAqBtoEgAANv .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ak8OTAqBtoEgAANv rect.text{fill:none;stroke-width:0;}#mermaid-svg-ak8OTAqBtoEgAANv .icon-shape,#mermaid-svg-ak8OTAqBtoEgAANv .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ak8OTAqBtoEgAANv .icon-shape p,#mermaid-svg-ak8OTAqBtoEgAANv .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ak8OTAqBtoEgAANv .icon-shape .label rect,#mermaid-svg-ak8OTAqBtoEgAANv .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ak8OTAqBtoEgAANv .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ak8OTAqBtoEgAANv .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ak8OTAqBtoEgAANv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} OpenBCI设备
EEG数据采集
数据预处理
数据存储
实时可视化
特征计算
指标计算
波形图
频谱图
功率图
指标仪表


核心组件设计

数据采集模块

python 复制代码
import numpy as np
from brainflow.board_shim import BoardShim, BrainFlowInputParams, BoardIds

class EEGDataCollector:
    def __init__(self, board_id=BoardIds.GANGLION_BOARD.value):
        self.board_id = board_id
        self.params = BrainFlowInputParams()
        self.board = None
        self.data_buffer = []
        self.max_buffer_size = 1000
    
    def connect(self):
        """连接设备"""
        try:
            self.board = BoardShim(self.board_id, self.params)
            self.board.prepare_session()
            self.board.start_stream()
            return True
        except Exception as e:
            print(f"连接失败: {e}")
            return False
    
    def disconnect(self):
        """断开连接"""
        if self.board:
            self.board.stop_stream()
            self.board.release_session()
    
    def get_data(self, num_samples=256):
        """获取数据"""
        if not self.board:
            # 模拟数据
            return np.random.randn(num_samples, 8)
        
        data = self.board.get_current_board_data(num_samples)
        return data[1:9, :].T  # 返回EEG通道
    
    def update_buffer(self, data):
        """更新缓冲区"""
        self.data_buffer.extend(data.tolist())
        if len(self.data_buffer) > self.max_buffer_size:
            self.data_buffer = self.data_buffer[-self.max_buffer_size:]
        
        return np.array(self.data_buffer)

信号处理模块

python 复制代码
import numpy as np
from scipy.signal import butter, lfilter, welch

class SignalProcessor:
    def __init__(self, fs=250):
        self.fs = fs
        self.bands = {
            'delta': (0.5, 4),
            'theta': (4, 8),
            'alpha': (8, 12),
            'beta': (13, 30),
            'gamma': (30, 50)
        }
    
    def butter_bandpass(self, lowcut, highcut, order=4):
        """设计巴特沃斯带通滤波器"""
        nyq = 0.5 * self.fs
        low = lowcut / nyq
        high = highcut / nyq
        b, a = butter(order, [low, high], btype='band')
        return b, a
    
    def filter_signal(self, data, lowcut=1, highcut=50):
        """滤波信号"""
        b, a = self.butter_bandpass(lowcut, highcut)
        filtered = lfilter(b, a, data, axis=0)
        return filtered
    
    def compute_psd(self, data):
        """计算功率谱密度"""
        n_channels = data.shape[1]
        psd_data = []
        
        for channel in range(n_channels):
            freqs, psd = welch(data[:, channel], fs=self.fs, nperseg=256)
            psd_data.append(psd)
        
        return freqs, np.array(psd_data)
    
    def compute_band_power(self, data):
        """计算各频段功率"""
        freqs, psd = self.compute_psd(data)
        band_powers = {}
        
        for band_name, (low, high) in self.bands.items():
            mask = (freqs >= low) & (freqs <= high)
            band_powers[band_name] = np.mean(psd[:, mask])
        
        return band_powers
    
    def compute_stats(self, data):
        """计算统计数据"""
        stats = {
            'mean': np.mean(data),
            'std': np.std(data),
            'max': np.max(data),
            'min': np.min(data),
            'rms': np.sqrt(np.mean(data**2))
        }
        return stats

可视化组件

实时波形图

python 复制代码
import matplotlib.pyplot as plt
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
import numpy as np

class WaveformPlot(FigureCanvas):
    def __init__(self, parent=None, n_channels=8):
        self.fig = Figure(figsize=(10, 6))
        self.axes = [self.fig.add_subplot(n_channels, 1, i+1) for i in range(n_channels)]
        
        self.n_channels = n_channels
        self.colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', 
                      '#9467bd', '#8c564b', '#e377c2', '#7f7f7f']
        self.lines = []
        
        for i, ax in enumerate(self.axes):
            line, = ax.plot([], [], color=self.colors[i], linewidth=0.5)
            self.lines.append(line)
            ax.set_ylim(-100, 100)
            ax.set_yticks([])
            if i < n_channels - 1:
                ax.set_xticks([])
            ax.grid(True, alpha=0.3)
        
        self.fig.tight_layout()
        super().__init__(self.fig)
        self.setParent(parent)
    
    def update(self, data):
        """更新波形"""
        n_samples = data.shape[0]
        t = np.arange(n_samples) / 250
        
        for i in range(self.n_channels):
            self.lines[i].set_data(t, data[:, i])
            self.axes[i].set_xlim(0, n_samples / 250)
        
        self.fig.canvas.draw()

频谱图

python 复制代码
class SpectrumPlot(FigureCanvas):
    def __init__(self, parent=None):
        self.fig = Figure(figsize=(8, 4))
        self.ax = self.fig.add_subplot(111)
        
        self.line, = self.ax.plot([], [], color='#1f77b4')
        self.ax.set_xlim(0, 50)
        self.ax.set_ylim(0, 1)
        self.ax.set_xlabel('频率 (Hz)')
        self.ax.set_ylabel('功率')
        self.ax.grid(True, alpha=0.3)
        
        # 频段标记
        bands = [(0.5, 4, 'delta'), (4, 8, 'theta'), (8, 12, 'alpha'), 
                 (13, 30, 'beta'), (30, 50, 'gamma')]
        colors = ['#9467bd', '#8c564b', '#1f77b4', '#ff7f0e', '#2ca02c']
        
        for (low, high, name), color in zip(bands, colors):
            self.ax.axvspan(low, high, color=color, alpha=0.1)
        
        super().__init__(self.fig)
        self.setParent(parent)
    
    def update(self, freqs, psd):
        """更新频谱"""
        avg_psd = np.mean(psd, axis=0)
        avg_psd = avg_psd / np.max(avg_psd)  # 归一化
        
        self.line.set_data(freqs, avg_psd)
        self.fig.canvas.draw()

频段功率图

python 复制代码
class BandPowerPlot(FigureCanvas):
    def __init__(self, parent=None):
        self.fig = Figure(figsize=(8, 4))
        self.ax = self.fig.add_subplot(111)
        
        self.bands = ['delta', 'theta', 'alpha', 'beta', 'gamma']
        self.colors = ['#9467bd', '#8c564b', '#1f77b4', '#ff7f0e', '#2ca02c']
        self.bars = None
        
        super().__init__(self.fig)
        self.setParent(parent)
    
    def update(self, band_powers):
        """更新频段功率"""
        self.ax.clear()
        
        values = [band_powers.get(band, 0) for band in self.bands]
        values = np.array(values) / np.sum(values) if np.sum(values) > 0 else values
        
        self.bars = self.ax.bar(self.bands, values, color=self.colors)
        self.ax.set_ylim(0, 1)
        self.ax.set_ylabel('相对功率')
        self.ax.grid(True, alpha=0.3, axis='y')
        
        self.fig.canvas.draw()

仪表板主界面

布局设计

#mermaid-svg-52OmbLGHYRtsxic6{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-52OmbLGHYRtsxic6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-52OmbLGHYRtsxic6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-52OmbLGHYRtsxic6 .error-icon{fill:#552222;}#mermaid-svg-52OmbLGHYRtsxic6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-52OmbLGHYRtsxic6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-52OmbLGHYRtsxic6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-52OmbLGHYRtsxic6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-52OmbLGHYRtsxic6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-52OmbLGHYRtsxic6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-52OmbLGHYRtsxic6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-52OmbLGHYRtsxic6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-52OmbLGHYRtsxic6 .marker.cross{stroke:#333333;}#mermaid-svg-52OmbLGHYRtsxic6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-52OmbLGHYRtsxic6 p{margin:0;}#mermaid-svg-52OmbLGHYRtsxic6 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-52OmbLGHYRtsxic6 .cluster-label text{fill:#333;}#mermaid-svg-52OmbLGHYRtsxic6 .cluster-label span{color:#333;}#mermaid-svg-52OmbLGHYRtsxic6 .cluster-label span p{background-color:transparent;}#mermaid-svg-52OmbLGHYRtsxic6 .label text,#mermaid-svg-52OmbLGHYRtsxic6 span{fill:#333;color:#333;}#mermaid-svg-52OmbLGHYRtsxic6 .node rect,#mermaid-svg-52OmbLGHYRtsxic6 .node circle,#mermaid-svg-52OmbLGHYRtsxic6 .node ellipse,#mermaid-svg-52OmbLGHYRtsxic6 .node polygon,#mermaid-svg-52OmbLGHYRtsxic6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-52OmbLGHYRtsxic6 .rough-node .label text,#mermaid-svg-52OmbLGHYRtsxic6 .node .label text,#mermaid-svg-52OmbLGHYRtsxic6 .image-shape .label,#mermaid-svg-52OmbLGHYRtsxic6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-52OmbLGHYRtsxic6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-52OmbLGHYRtsxic6 .rough-node .label,#mermaid-svg-52OmbLGHYRtsxic6 .node .label,#mermaid-svg-52OmbLGHYRtsxic6 .image-shape .label,#mermaid-svg-52OmbLGHYRtsxic6 .icon-shape .label{text-align:center;}#mermaid-svg-52OmbLGHYRtsxic6 .node.clickable{cursor:pointer;}#mermaid-svg-52OmbLGHYRtsxic6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-52OmbLGHYRtsxic6 .arrowheadPath{fill:#333333;}#mermaid-svg-52OmbLGHYRtsxic6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-52OmbLGHYRtsxic6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-52OmbLGHYRtsxic6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-52OmbLGHYRtsxic6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-52OmbLGHYRtsxic6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-52OmbLGHYRtsxic6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-52OmbLGHYRtsxic6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-52OmbLGHYRtsxic6 .cluster text{fill:#333;}#mermaid-svg-52OmbLGHYRtsxic6 .cluster span{color:#333;}#mermaid-svg-52OmbLGHYRtsxic6 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-52OmbLGHYRtsxic6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-52OmbLGHYRtsxic6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-52OmbLGHYRtsxic6 .icon-shape,#mermaid-svg-52OmbLGHYRtsxic6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-52OmbLGHYRtsxic6 .icon-shape p,#mermaid-svg-52OmbLGHYRtsxic6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-52OmbLGHYRtsxic6 .icon-shape .label rect,#mermaid-svg-52OmbLGHYRtsxic6 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-52OmbLGHYRtsxic6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-52OmbLGHYRtsxic6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-52OmbLGHYRtsxic6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 仪表板
顶部状态栏
连接状态
采样率
数据速率
左侧面板
实时波形图
中间面板
频谱分析图
频段功率图
右侧面板
注意力指标
情绪指标
统计数据
底部控制栏
开始/停止按钮
数据记录按钮
历史回放

PyQt5实现

python 复制代码
import sys
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
                             QHBoxLayout, QLabel, QPushButton, QGroupBox,
                             QGridLayout, QProgressBar)
from PyQt5.QtCore import QThread, pyqtSignal
import numpy as np

class DashboardWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("脑电数据可视化仪表板")
        self.setGeometry(100, 100, 1400, 900)
        
        # 初始化组件
        self.collector = EEGDataCollector()
        self.processor = SignalProcessor()
        
        # 布局
        central = QWidget()
        self.setCentralWidget(central)
        main_layout = QVBoxLayout(central)
        
        # 状态栏
        status_bar = QWidget()
        status_layout = QHBoxLayout(status_bar)
        
        self.status_label = QLabel("状态: 未连接")
        self.rate_label = QLabel("采样率: 250 Hz")
        self.data_label = QLabel("数据速率: 0 samples/s")
        
        status_layout.addWidget(self.status_label)
        status_layout.addWidget(self.rate_label)
        status_layout.addWidget(self.data_label)
        main_layout.addWidget(status_bar)
        
        # 主内容区
        content_layout = QHBoxLayout()
        
        # 左侧:波形图
        left_panel = QGroupBox("实时波形")
        left_layout = QVBoxLayout(left_panel)
        self.waveform_plot = WaveformPlot()
        left_layout.addWidget(self.waveform_plot)
        content_layout.addWidget(left_panel, 3)
        
        # 右侧:控制面板
        right_panel = QWidget()
        right_layout = QVBoxLayout(right_panel)
        
        # 频谱图
        spectrum_group = QGroupBox("频谱分析")
        spectrum_layout = QVBoxLayout(spectrum_group)
        self.spectrum_plot = SpectrumPlot()
        spectrum_layout.addWidget(self.spectrum_plot)
        right_layout.addWidget(spectrum_group)
        
        # 频段功率
        power_group = QGroupBox("频段功率")
        power_layout = QVBoxLayout(power_group)
        self.power_plot = BandPowerPlot()
        power_layout.addWidget(self.power_plot)
        right_layout.addWidget(power_group)
        
        # 指标显示
        metrics_group = QGroupBox("实时指标")
        metrics_layout = QGridLayout(metrics_group)
        
        self.attention_bar = QProgressBar()
        self.attention_bar.setRange(0, 100)
        metrics_layout.addWidget(QLabel("注意力:"), 0, 0)
        metrics_layout.addWidget(self.attention_bar, 0, 1)
        
        self.emotion_bar = QProgressBar()
        self.emotion_bar.setRange(0, 100)
        metrics_layout.addWidget(QLabel("情绪指数:"), 1, 0)
        metrics_layout.addWidget(self.emotion_bar, 1, 1)
        
        right_layout.addWidget(metrics_group)
        
        # 统计数据
        stats_group = QGroupBox("统计数据")
        stats_layout = QGridLayout(stats_group)
        self.stats_labels = {}
        for i, stat in enumerate(['均值', '标准差', '峰值', 'RMS']):
            label = QLabel(f"{stat}: --")
            self.stats_labels[stat] = label
            stats_layout.addWidget(label, i//2, i%2)
        right_layout.addWidget(stats_group)
        
        content_layout.addWidget(right_panel, 2)
        main_layout.addLayout(content_layout)
        
        # 控制栏
        control_bar = QWidget()
        control_layout = QHBoxLayout(control_bar)
        
        self.connect_btn = QPushButton("连接设备")
        self.connect_btn.clicked.connect(self.connect_device)
        control_layout.addWidget(self.connect_btn)
        
        self.start_btn = QPushButton("开始采集")
        self.start_btn.clicked.connect(self.start_collection)
        self.start_btn.setEnabled(False)
        control_layout.addWidget(self.start_btn)
        
        self.stop_btn = QPushButton("停止采集")
        self.stop_btn.clicked.connect(self.stop_collection)
        self.stop_btn.setEnabled(False)
        control_layout.addWidget(self.stop_btn)
        
        main_layout.addWidget(control_bar)
        
        # 工作线程
        self.worker = DataWorker(self.collector, self.processor)
        self.worker.data_ready.connect(self.update_plots)
    
    def connect_device(self):
        if self.collector.connect():
            self.status_label.setText("状态: 已连接")
            self.connect_btn.setEnabled(False)
            self.start_btn.setEnabled(True)
    
    def start_collection(self):
        self.worker.start()
        self.start_btn.setEnabled(False)
        self.stop_btn.setEnabled(True)
    
    def stop_collection(self):
        self.worker.running = False
        self.start_btn.setEnabled(True)
        self.stop_btn.setEnabled(False)
    
    def update_plots(self, data, psd, band_powers, stats, attention, emotion):
        # 更新波形
        self.waveform_plot.update(data)
        
        # 更新频谱
        freqs = np.linspace(0, 50, len(psd[0]))
        self.spectrum_plot.update(freqs, psd)
        
        # 更新频段功率
        self.power_plot.update(band_powers)
        
        # 更新统计数据
        self.stats_labels['均值'].setText(f"均值: {stats['mean']:.2f}")
        self.stats_labels['标准差'].setText(f"标准差: {stats['std']:.2f}")
        self.stats_labels['峰值'].setText(f"峰值: {stats['max']:.2f}")
        self.stats_labels['RMS'].setText(f"RMS: {stats['rms']:.2f}")
        
        # 更新指标
        self.attention_bar.setValue(int(attention * 100))
        self.emotion_bar.setValue(int(emotion * 100))
        
        # 更新数据速率
        self.data_label.setText(f"数据速率: {len(data)} samples/s")

class DataWorker(QThread):
    data_ready = pyqtSignal(np.ndarray, np.ndarray, dict, dict, float, float)
    
    def __init__(self, collector, processor):
        super().__init__()
        self.collector = collector
        self.processor = processor
        self.running = True
    
    def run(self):
        while self.running:
            # 获取数据
            raw_data = self.collector.get_data(256)
            
            # 滤波
            filtered_data = self.processor.filter_signal(raw_data)
            
            # 计算PSD
            freqs, psd = self.processor.compute_psd(filtered_data)
            
            # 计算频段功率
            band_powers = self.processor.compute_band_power(filtered_data)
            
            # 计算统计数据
            stats = self.processor.compute_stats(filtered_data)
            
            # 模拟注意力和情绪
            attention = 0.3 + 0.4 * np.random.random()
            emotion = 0.4 + 0.4 * np.random.random()
            
            # 发送信号
            self.data_ready.emit(filtered_data, psd, band_powers, stats, attention, emotion)
            
            time.sleep(0.1)

历史数据回放

数据管理器

python 复制代码
import sqlite3
from datetime import datetime

class DataManager:
    def __init__(self, db_name='eeg_data.db'):
        self.conn = sqlite3.connect(db_name)
        self._create_tables()
    
    def _create_tables(self):
        cursor = self.conn.cursor()
        cursor.execute('''
            CREATE TABLE IF NOT EXISTS recordings (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                start_time TEXT,
                end_time TEXT,
                duration REAL
            )
        ''')
        cursor.execute('''
            CREATE TABLE IF NOT EXISTS eeg_data (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                recording_id INTEGER,
                timestamp REAL,
                channel_1 REAL,
                channel_2 REAL,
                channel_3 REAL,
                channel_4 REAL,
                channel_5 REAL,
                channel_6 REAL,
                channel_7 REAL,
                channel_8 REAL,
                FOREIGN KEY (recording_id) REFERENCES recordings(id)
            )
        ''')
        self.conn.commit()
    
    def start_recording(self):
        cursor = self.conn.cursor()
        cursor.execute('INSERT INTO recordings (start_time) VALUES (?)', 
                      (datetime.now().isoformat(),))
        self.conn.commit()
        return cursor.lastrowid
    
    def end_recording(self, recording_id):
        cursor = self.conn.cursor()
        cursor.execute('UPDATE recordings SET end_time = ? WHERE id = ?', 
                      (datetime.now().isoformat(), recording_id))
        self.conn.commit()
    
    def save_data(self, recording_id, timestamp, data):
        cursor = self.conn.cursor()
        cursor.execute('''
            INSERT INTO eeg_data (recording_id, timestamp, channel_1, channel_2, 
                                channel_3, channel_4, channel_5, channel_6, 
                                channel_7, channel_8)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        ''', (recording_id, timestamp, *data))
        self.conn.commit()
    
    def get_recordings(self):
        cursor = self.conn.cursor()
        cursor.execute('SELECT * FROM recordings ORDER BY start_time DESC')
        return cursor.fetchall()
    
    def get_recording_data(self, recording_id):
        cursor = self.conn.cursor()
        cursor.execute('SELECT * FROM eeg_data WHERE recording_id = ? ORDER BY timestamp', 
                      (recording_id,))
        return cursor.fetchall()

回放控件

python 复制代码
class PlaybackControl(QWidget):
    def __init__(self, data_manager):
        super().__init__()
        self.data_manager = data_manager
        self.current_recording = None
        self.current_position = 0
        self.playback_data = []
        
        layout = QVBoxLayout()
        
        # 录音列表
        self.recording_list = QListWidget()
        self.refresh_recordings()
        self.recording_list.itemClicked.connect(self.select_recording)
        layout.addWidget(self.recording_list)
        
        # 控制按钮
        control_layout = QHBoxLayout()
        
        self.play_btn = QPushButton("播放")
        self.play_btn.clicked.connect(self.play)
        control_layout.addWidget(self.play_btn)
        
        self.pause_btn = QPushButton("暂停")
        self.pause_btn.clicked.connect(self.pause)
        control_layout.addWidget(self.pause_btn)
        
        self.stop_btn = QPushButton("停止")
        self.stop_btn.clicked.connect(self.stop)
        control_layout.addWidget(self.stop_btn)
        
        # 进度条
        self.progress_bar = QProgressBar()
        control_layout.addWidget(self.progress_bar)
        
        layout.addLayout(control_layout)
        self.setLayout(layout)
    
    def refresh_recordings(self):
        """刷新录音列表"""
        self.recording_list.clear()
        recordings = self.data_manager.get_recordings()
        for rec in recordings:
            self.recording_list.addItem(f"{rec[1][:19]}")
    
    def select_recording(self, item):
        """选择录音"""
        recordings = self.data_manager.get_recordings()
        index = self.recording_list.row(item)
        self.current_recording = recordings[index][0]
        
        # 加载数据
        raw_data = self.data_manager.get_recording_data(self.current_recording)
        self.playback_data = []
        for row in raw_data:
            self.playback_data.append([row[3], row[4], row[5], row[6], 
                                      row[7], row[8], row[9], row[10]])
        
        self.current_position = 0
        self.progress_bar.setRange(0, len(self.playback_data))
    
    def play(self):
        """播放"""
        if self.playback_data:
            # 启动播放线程
            self.play_thread = PlaybackThread(self.playback_data)
            self.play_thread.data_ready.connect(self.on_playback_data)
            self.play_thread.start()
    
    def pause(self):
        """暂停"""
        if hasattr(self, 'play_thread'):
            self.play_thread.pause()
    
    def stop(self):
        """停止"""
        if hasattr(self, 'play_thread'):
            self.play_thread.stop()
        self.current_position = 0
        self.progress_bar.setValue(0)
    
    def on_playback_data(self, data, position):
        """处理回放数据"""
        self.current_position = position
        self.progress_bar.setValue(position)
        # 发送数据到可视化组件
        self.parent().update_plots(np.array(data), None, {}, {}, 0.5, 0.5)

完整主程序

python 复制代码
import sys
import numpy as np
import time
from PyQt5.QtWidgets import QApplication
from PyQt5.QtCore import QThread, pyqtSignal

# 数据采集器
class EEGDataCollector:
    def connect(self):
        return True
    def get_data(self, num_samples):
        return np.random.randn(num_samples, 8)

# 信号处理器
class SignalProcessor:
    def filter_signal(self, data):
        return data
    def compute_psd(self, data):
        freqs = np.linspace(0, 50, 128)
        psd = np.random.rand(8, 128)
        return freqs, psd
    def compute_band_power(self, data):
        return {'delta': 0.1, 'theta': 0.2, 'alpha': 0.3, 'beta': 0.25, 'gamma': 0.15}
    def compute_stats(self, data):
        return {'mean': np.mean(data), 'std': np.std(data), 'max': np.max(data), 'rms': np.sqrt(np.mean(data**2))}

# 主窗口
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("BCI仪表板")
        self.setGeometry(100, 100, 1000, 700)
        
        from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
        central = QWidget()
        self.setCentralWidget(central)
        layout = QVBoxLayout(central)
        
        self.label = QLabel("准备就绪")
        layout.addWidget(self.label)
        
        self.start_btn = QPushButton("开始")
        self.start_btn.clicked.connect(self.start)
        layout.addWidget(self.start_btn)
        
        self.stop_btn = QPushButton("停止")
        self.stop_btn.clicked.connect(self.stop)
        self.stop_btn.setEnabled(False)
        layout.addWidget(self.stop_btn)
    
    def start(self):
        self.start_btn.setEnabled(False)
        self.stop_btn.setEnabled(True)
        self.worker = DataWorker()
        self.worker.data_ready.connect(self.update)
        self.worker.start()
    
    def stop(self):
        self.worker.running = False
        self.start_btn.setEnabled(True)
        self.stop_btn.setEnabled(False)
    
    def update(self, stats):
        self.label.setText(f"均值: {stats['mean']:.2f}, 标准差: {stats['std']:.2f}")

class DataWorker(QThread):
    data_ready = pyqtSignal(dict)
    
    def __init__(self):
        super().__init__()
        self.running = True
        self.processor = SignalProcessor()
    
    def run(self):
        while self.running:
            data = np.random.randn(256, 8)
            stats = self.processor.compute_stats(data)
            self.data_ready.emit(stats)
            time.sleep(0.1)

if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec_())

部署与运行

安装依赖

bash 复制代码
pip install pyqt5 matplotlib numpy scipy brainflow

运行命令

bash 复制代码
python dashboard.py

总结

通过这个项目,你学会了:

  1. 实时数据采集:从OpenBCI设备获取EEG数据
  2. 信号处理:滤波、频谱分析、功率计算
  3. 可视化:波形图、频谱图、功率图
  4. 数据管理:记录和回放历史数据

思考与练习

  1. 思考:如何优化实时绘图性能?
  2. 练习:添加3D可视化功能
  3. 练习:实现数据导出功能(CSV/Matlab格式)

系列文章导航


声明:本文仅供学习交流,文中涉及的硬件设备和软件工具请从官方渠道获取。脑机接口技术涉及生物医学领域,实际应用请遵循相关法律法规和伦理规范。


相关推荐
Summer-Bright35 分钟前
小模型 Agent 闯进安全禁区:27B 本地逆向、$266 越狱平板刷屏 —— AI 应用简报 08.20-08.24
人工智能·安全·电脑
delishcomcn1 小时前
AI视觉+烫金箔分切:为精密制造装上“火眼金睛”
人工智能·制造
七牛云行业应用2 小时前
Cursor 如何接入自定义模型:Override Base URL 完整配置与四类踩坑速查
人工智能·agent·ai编程
MartinYeung52 小时前
[论文学习]PoisonBench:评估语言模型对投毒偏好数据的脆弱性
人工智能·学习·语言模型
ting94520008 小时前
Humalike X Hermes 深度技术剖析:单指令注入群聊社交智能的底层架构、算法与跨 IM 平台实现
人工智能·算法·架构
涤生大数据8 小时前
一次“没有运行日志”的DolphinScheduler任务失败排查
大数据·数据库·人工智能·状态模式
东风破_8 小时前
Vibe Coding 上头之后,我开始用 SDD 给 AI 编程加一张“施工图”
人工智能·程序员
weixin_403810138 小时前
AI智能体写安卓自动化脚本教程:Cursor/Trae+CLI 实战,自然语言生成代码
android·人工智能·自动化·跨境电商·安卓自动化脚本·多账户运营
深圳雨林凯AI9 小时前
雨林凯AI四方连图介质适配原理:像素密度、纱线纹理与颜色管理怎么协调
人工智能