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格式)

系列文章导航


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


相关推荐
七夜zippoe3 小时前
OpenClaw Canvas A2UI:AI驱动的交互式界面开发实战
人工智能·canvas·交互式·a2ui·openclaw
北京盟通科技官方账号3 小时前
NVIDIA Jetson 全球生态链分析:acontis(代表产品EC-Master)在机器人 EtherCAT 赛道的硬核价值
人工智能·机器人·ethercat·技术原理·盟通科技·ec-master·acontis
jkyy20143 小时前
深耕车载数字健康场景,守护全维度驾乘安全与体验
人工智能·安全·汽车
kuaixunbao3 小时前
哪些工具可以监测GEO排名?监测、分析、优化、效果评估全链路服务
人工智能
勇往直前plus3 小时前
智能体记忆概述
人工智能·python·ai
Roc-xb3 小时前
Hermes Agent 安装详细教程
人工智能·hermes agent
做个文艺程序员3 小时前
第08篇:K8s 部署 AI 大模型推理服务:GPU 调度 × vLLM × Java 客户端集成——从 0 到生产的完整方案
人工智能·kubernetes·vllm
IT_陈寒3 小时前
Vite静态资源引用差点把我逼疯,原来要这样处理
前端·人工智能·后端
V搜xhliang02463 小时前
临床科研新范式:从选题到投稿,AI智能体如何接管全流程?
运维·数据结构·人工智能·算法·microsoft·数据挖掘·自动化