
OpenBCI-实战五:脑电数据可视化仪表板
文章目录
引言
在前几篇实战文章中,我们学习了注意力监测、情绪识别和脑波控制等应用。现在,让我们来构建一个综合性的脑电数据可视化仪表板。
数据可视化是BCI系统中非常重要的一环,它可以帮助用户直观地理解脑电信号的特征和变化趋势。一个好的可视化仪表板应该能够实时显示多种数据类型,包括原始波形、频谱分析、统计数据等。
项目概述
仪表板功能
- 实时波形显示:显示各通道的原始EEG波形
- 频谱分析:实时FFT频谱图
- 频段功率:各频段功率条形图
- 注意力/情绪指标:实时数值和趋势图
- 数据统计:均值、标准差、峰值等统计数据
- 历史回放:支持历史数据的回放和分析
技术栈
| 组件 | 技术 | 说明 |
|---|---|---|
| 后端 | 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
总结
通过这个项目,你学会了:
- 实时数据采集:从OpenBCI设备获取EEG数据
- 信号处理:滤波、频谱分析、功率计算
- 可视化:波形图、频谱图、功率图
- 数据管理:记录和回放历史数据
思考与练习
- 思考:如何优化实时绘图性能?
- 练习:添加3D可视化功能
- 练习:实现数据导出功能(CSV/Matlab格式)
系列文章导航:
- 第1篇:什么是脑机接口(BCI)?OpenBCI入门指南
- 第2篇:OpenBCI硬件选型:Cyton vs Ganglion对比分析
- 第3篇:搭建你的第一个脑电采集系统
- 第4篇:OpenBCI GUI使用指南:从安装到数据采集
- 第5篇:脑电信号基础:EEG波形解读与频段分析
- 第6篇:BrainFlow SDK完全指南:统一API实现多设备兼容
- 第7篇:Python与OpenBCI:实时脑电信号采集实战
- 第8篇:信号预处理:滤波、去噪与伪迹去除
- 第9篇:特征提取技术:频域分析与时频分析
- 第10篇:机器学习入门:从脑电信号到模式识别
- 第11篇:实战一:脑波控制LED灯(基础)
- 第12篇:实战二:脑波控制小游戏开发
- 第13篇:实战三:注意力监测系统
- 第14篇:实战四:情绪识别与反馈系统
- 第15篇:实战五:脑电数据可视化仪表板(当前)
- 第16篇:运动想象BCI:从信号采集到动作识别
声明:本文仅供学习交流,文中涉及的硬件设备和软件工具请从官方渠道获取。脑机接口技术涉及生物医学领域,实际应用请遵循相关法律法规和伦理规范。
