基于QT的串口心电波形实时显示系统

1. 项目配置和主窗口

1.1 项目文件 (.pro)

qmake 复制代码
QT       += core gui charts serialport

greaterThan(QT_MAJOR_VERSION, 4): QT += widgets

CONFIG += c++11

SOURCES += \
    main.cpp \
    mainwindow.cpp \
    serialportreader.cpp \
    ecgchart.cpp

HEADERS += \
    mainwindow.h \
    serialportreader.h \
    ecgchart.h

FORMS += \
    mainwindow.ui

# 资源文件
RESOURCES += \
    resources.qrc

1.2 主窗口头文件 (mainwindow.h)

cpp 复制代码
#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>
#include <QSerialPort>
#include <QSerialPortInfo>
#include <QChart>
#include <QChartView>
#include <QLineSeries>
#include <QValueAxis>
#include <QTimer>
#include <QFile>
#include <QDateTime>
#include <QMessageBox>
#include <QDebug>

#include "ecgchart.h"

QT_BEGIN_NAMESPACE
namespace Ui { class MainWindow; }
QT_END_NAMESPACE

class MainWindow : public QMainWindow
{
    Q_OBJECT

public:
    MainWindow(QWidget *parent = nullptr);
    ~MainWindow();

private slots:
    // 串口相关槽函数
    void on_btnOpenPort_clicked();
    void on_btnClosePort_clicked();
    void on_btnRefreshPorts_clicked();
    void on_btnStartRecord_clicked();
    void on_btnStopRecord_clicked();
    
    // 串口数据处理
    void readSerialData();
    void handleError(QSerialPort::SerialPortError error);
    
    // 图表控制
    void on_btnPause_clicked();
    void on_btnClear_clicked();
    void on_sliderSpeed_valueChanged(int value);
    void on_sliderGain_valueChanged(int value);
    
    // 滤波器设置
    void on_cbFilter_currentIndexChanged(int index);
    
    // 数据保存
    void saveECGData();
    
private:
    Ui::MainWindow *ui;
    
    // 串口对象
    QSerialPort *serialPort;
    
    // 图表对象
    ECGChart *ecgChart;
    
    // 定时器
    QTimer *dataTimer;
    QTimer *saveTimer;
    
    // 数据缓冲区
    QVector<qreal> ecgDataBuffer;
    QByteArray serialBuffer;
    
    // 记录状态
    bool isRecording;
    QString currentRecordFile;
    
    // 设置默认参数
    void setupDefaultParameters();
    
    // 初始化串口列表
    void initSerialPorts();
    
    // 初始化图表
    void initChart();
    
    // 处理接收到的数据
    void processECGData(const QByteArray &data);
    
    // 解析数据包
    bool parseDataPacket(const QByteArray &packet, qint16 &ecgValue, qint16 &heartRate);
    
    // 保存数据到文件
    void writeDataToFile(const QString &data);
    
    // 滤波处理
    qreal applyFilter(qreal value);
    
    // 移动平均滤波
    qreal movingAverageFilter(qreal value);
    
    // 中值滤波
    qreal medianFilter(qreal value);
    
    // IIR滤波
    qreal iirFilter(qreal value);
    
    // 滤波缓冲区
    QVector<qreal> maBuffer;
    QVector<qreal> medianBuffer;
    
    // IIR滤波器系数
    qreal iirAlpha;  // 低通滤波系数
    qreal iirLastValue;
};
#endif // MAINWINDOW_H

1.3 主窗口实现文件 (mainwindow.cpp)

cpp 复制代码
#include "mainwindow.h"
#include "ui_mainwindow.h"

MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent)
    , ui(new Ui::MainWindow)
    , serialPort(nullptr)
    , isRecording(false)
    , iirAlpha(0.1)
    , iirLastValue(0.0)
{
    ui->setupUi(this);
    
    // 设置窗口标题
    setWindowTitle("心电波形实时显示系统");
    
    // 初始化UI
    setupDefaultParameters();
    
    // 初始化串口
    initSerialPorts();
    
    // 初始化图表
    initChart();
    
    // 初始化定时器
    dataTimer = new QTimer(this);
    connect(dataTimer, &QTimer::timeout, this, &MainWindow::readSerialData);
    
    saveTimer = new QTimer(this);
    connect(saveTimer, &QTimer::timeout, this, &MainWindow::saveECGData);
    
    // 初始化滤波器缓冲区
    maBuffer.resize(5, 0.0);
    medianBuffer.resize(5, 0.0);
}

MainWindow::~MainWindow()
{
    if (serialPort && serialPort->isOpen()) {
        serialPort->close();
    }
    
    if (isRecording) {
        writeDataToFile("--- Recording Stopped ---\n");
    }
    
    delete ui;
}

void MainWindow::setupDefaultParameters()
{
    // 设置默认串口参数
    ui->cbBaudRate->setCurrentText("115200");
    ui->cbDataBits->setCurrentText("8");
    ui->cbParity->setCurrentText("None");
    ui->cbStopBits->setCurrentText("1");
    ui->cbFlowControl->setCurrentText("None");
    
    // 设置默认显示参数
    ui->sliderSpeed->setValue(50);
    ui->sliderGain->setValue(100);
    
    // 设置默认滤波器
    ui->cbFilter->setCurrentIndex(0);
    
    // 初始化按钮状态
    ui->btnOpenPort->setEnabled(true);
    ui->btnClosePort->setEnabled(false);
    ui->btnStartRecord->setEnabled(false);
    ui->btnStopRecord->setEnabled(false);
    ui->btnPause->setEnabled(false);
    ui->btnClear->setEnabled(true);
}

void MainWindow::initSerialPorts()
{
    ui->cbPortName->clear();
    
    // 获取所有可用的串口
    QList<QSerialPortInfo> ports = QSerialPortInfo::availablePorts();
    
    for (const QSerialPortInfo &port : ports) {
        QString portInfo = QString("%1 - %2")
                              .arg(port.portName())
                              .arg(port.description());
        ui->cbPortName->addItem(portInfo, port.portName());
    }
    
    if (ports.isEmpty()) {
        ui->cbPortName->addItem("未找到串口设备");
        ui->btnOpenPort->setEnabled(false);
    }
}

void MainWindow::initChart()
{
    // 创建心电图表
    ecgChart = new ECGChart();
    ecgChart->setAnimationOptions(QChart::NoAnimation);
    ecgChart->setTheme(QChart::ChartThemeDark);
    
    // 将图表添加到UI
    QChartView *chartView = new QChartView(ecgChart);
    chartView->setRenderHint(QPainter::Antialiasing);
    ui->chartLayout->addWidget(chartView);
    
    // 设置初始参数
    ecgChart->setDisplaySpeed(50);
    ecgChart->setGain(1.0);
    
    // 连接图表信号
    connect(ecgChart, &ECGChart::heartRateUpdated, 
            this, int hr {
                ui->lblHeartRate->setText(QString::number(hr));
            });
}

void MainWindow::on_btnRefreshPorts_clicked()
{
    initSerialPorts();
}

void MainWindow::on_btnOpenPort_clicked()
{
    if (serialPort && serialPort->isOpen()) {
        return;
    }
    
    // 获取选中的串口名
    QString portName = ui->cbPortName->currentData().toString();
    if (portName.isEmpty()) {
        QMessageBox::warning(this, "警告", "请选择有效的串口");
        return;
    }
    
    // 创建串口对象
    serialPort = new QSerialPort(this);
    serialPort->setPortName(portName);
    
    // 设置串口参数
    serialPort->setBaudRate(ui->cbBaudRate->currentText().toInt());
    serialPort->setDataBits(static_cast<QSerialPort::DataBits>(
        ui->cbDataBits->currentText().toInt()));
    serialPort->setParity(static_cast<QSerialPort::Parity>(
        ui->cbParity->currentIndex()));
    serialPort->setStopBits(static_cast<QSerialPort::StopBits>(
        ui->cbStopBits->currentIndex() + 1));
    serialPort->setFlowControl(static_cast<QSerialPort::FlowControl>(
        ui->cbFlowControl->currentIndex()));
    
    // 打开串口
    if (serialPort->open(QIODevice::ReadWrite)) {
        ui->statusbar->showMessage(QString("已连接到 %1").arg(portName), 3000);
        
        // 连接信号槽
        connect(serialPort, &QSerialPort::readyRead, this, &MainWindow::readSerialData);
        connect(serialPort, static_cast<void(QSerialPort::*)(QSerialPort::SerialPortError)>(&QSerialPort::error),
                this, &MainWindow::handleError);
        
        // 更新按钮状态
        ui->btnOpenPort->setEnabled(false);
        ui->btnClosePort->setEnabled(true);
        ui->btnStartRecord->setEnabled(true);
        ui->btnPause->setEnabled(true);
        ui->btnRefreshPorts->setEnabled(false);
        
        // 清空数据缓冲区
        ecgDataBuffer.clear();
        serialBuffer.clear();
        
        // 开始定时读取
        dataTimer->start(20);  // 50Hz更新频率
        
    } else {
        QMessageBox::critical(this, "错误", 
            QString("无法打开串口 %1: %2")
                .arg(portName)
                .arg(serialPort->errorString()));
        delete serialPort;
        serialPort = nullptr;
    }
}

void MainWindow::on_btnClosePort_clicked()
{
    if (serialPort && serialPort->isOpen()) {
        serialPort->close();
        delete serialPort;
        serialPort = nullptr;
        
        dataTimer->stop();
        
        ui->btnOpenPort->setEnabled(true);
        ui->btnClosePort->setEnabled(false);
        ui->btnStartRecord->setEnabled(false);
        ui->btnStopRecord->setEnabled(false);
        ui->btnPause->setEnabled(false);
        ui->btnRefreshPorts->setEnabled(true);
        
        ui->statusbar->showMessage("串口已关闭", 3000);
    }
}

void MainWindow::readSerialData()
{
    if (!serialPort || !serialPort->isOpen()) {
        return;
    }
    
    // 读取所有可用数据
    QByteArray newData = serialPort->readAll();
    
    if (newData.isEmpty()) {
        return;
    }
    
    // 添加到缓冲区
    serialBuffer.append(newData);
    
    // 处理完整的数据包
    // 假设数据格式: 起始标志(0xAA 0x55) + ECG数据(2字节) + 心率(2字节) + 校验和(1字节)
    while (serialBuffer.size() >= 7) {  // 7字节数据包
        // 查找起始标志
        int startIndex = serialBuffer.indexOf("\xAA\x55");
        if (startIndex == -1) {
            // 没有找到起始标志,清空无效数据
            serialBuffer.clear();
            break;
        }
        
        if (startIndex > 0) {
            // 移除起始标志前的无效数据
            serialBuffer = serialBuffer.mid(startIndex);
        }
        
        if (serialBuffer.size() < 7) {
            // 数据不完整,等待更多数据
            break;
        }
        
        // 提取完整数据包
        QByteArray packet = serialBuffer.left(7);
        serialBuffer = serialBuffer.mid(7);
        
        // 解析数据包
        qint16 ecgValue = 0;
        qint16 heartRate = 0;
        
        if (parseDataPacket(packet, ecgValue, heartRate)) {
            // 滤波处理
            qreal filteredValue = applyFilter(ecgValue);
            
            // 更新图表
            ecgChart->addDataPoint(filteredValue);
            
            // 显示心率
            ui->lblHeartRate->setText(QString::number(heartRate));
            
            // 保存数据
            if (isRecording) {
                QString dataLine = QString("%1,%2,%3\n")
                                     .arg(QDateTime::currentDateTime().toString("hh:mm:ss.zzz"))
                                     .arg(ecgValue)
                                     .arg(heartRate);
                writeDataToFile(dataLine);
            }
        }
    }
}

bool MainWindow::parseDataPacket(const QByteArray &packet, qint16 &ecgValue, qint16 &heartRate)
{
    if (packet.size() != 7) {
        return false;
    }
    
    // 检查起始标志
    if (static_cast<quint8>(packet[0]) != 0xAA || 
        static_cast<quint8>(packet[1]) != 0x55) {
        return false;
    }
    
    // 解析ECG数据 (小端模式)
    ecgValue = static_cast<qint16>(
        (static_cast<quint8>(packet[2]) << 8) | 
         static_cast<quint8>(packet[3]));
    
    // 解析心率数据
    heartRate = static_cast<qint16>(
        (static_cast<quint8>(packet[4]) << 8) | 
         static_cast<quint8>(packet[5]));
    
    // 计算校验和 (简单的异或校验)
    quint8 checksum = 0;
    for (int i = 0; i < 6; ++i) {
        checksum ^= static_cast<quint8>(packet[i]);
    }
    
    quint8 receivedChecksum = static_cast<quint8>(packet[6]);
    
    return checksum == receivedChecksum;
}

void MainWindow::handleError(QSerialPort::SerialPortError error)
{
    if (error == QSerialPort::ResourceError) {
        QMessageBox::critical(this, "串口错误", 
            QString("串口发生错误: %1").arg(serialPort->errorString()));
        on_btnClosePort_clicked();
    }
}

void MainWindow::on_btnStartRecord_clicked()
{
    if (!serialPort || !serialPort->isOpen()) {
        return;
    }
    
    // 生成文件名
    QString timestamp = QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss");
    currentRecordFile = QString("ecg_data_%1.csv").arg(timestamp);
    
    // 创建文件并写入表头
    QString header = "Timestamp,ECG_Value,Heart_Rate\n";
    writeDataToFile(header);
    
    isRecording = true;
    ui->btnStartRecord->setEnabled(false);
    ui->btnStopRecord->setEnabled(true);
    
    // 启动自动保存定时器
    saveTimer->start(5000);  // 每5秒自动保存
    
    ui->statusbar->showMessage(QString("开始记录: %1").arg(currentRecordFile), 3000);
}

void MainWindow::on_btnStopRecord_clicked()
{
    if (!isRecording) {
        return;
    }
    
    isRecording = false;
    ui->btnStartRecord->setEnabled(true);
    ui->btnStopRecord->setEnabled(false);
    
    saveTimer->stop();
    
    // 写入结束标记
    writeDataToFile("--- Recording Stopped ---\n");
    
    ui->statusbar->showMessage(QString("记录已停止: %1").arg(currentRecordFile), 3000);
}

void MainWindow::saveECGData()
{
    // 刷新文件缓冲区
    if (isRecording) {
        QFile file(currentRecordFile);
        if (file.isOpen()) {
            // 可以在这里添加额外的保存逻辑
        }
    }
}

void MainWindow::writeDataToFile(const QString &data)
{
    QFile file(currentRecordFile);
    
    if (file.open(QIODevice::Append | QIODevice::Text)) {
        QTextStream stream(&file);
        stream << data;
        file.close();
    }
}

void MainWindow::on_btnPause_clickedaggio()
{
    static bool isPaused = false;
    
    if (isPaused) {
        // 恢复显示
        if (serialPort && serialPort->isOpen()) {
            dataTimer->start();
        }
        ecgChart->resume();
        ui->btnPause->setText("暂停");
        isPaused = false;
    } else {
        // 暂停显示
        dataTimer->stop();
        ecgChart->pause();
        ui->btnPause->setText("继续");
        isPaused = true;
    }
}

void MainWindow::on_btnClear_clicked()
{
    ecgChart->clear();
    ecgDataBuffer.clear();
    ui->lblHeartRate->setText("--");
}

void MainWindow::on_sliderSpeed_valueChanged(int value)
{
    qreal speed = value / 100.0 * 5.0;  // 0-5秒显示范围
    ecgChart->setDisplaySpeed(value);
    ui->lblSpeed->setText(QString("%1 s").arg(speed, 0, 'f', 1));
}

void MainWindow::on_sliderGain_valueChanged(int value)
{
    qreal gain = value / 100.0;
    ecgChart->setGain(gain);
    ui->lblGain->setText(QString("x%1").arg(gain, 0, 'f', 2));
}

void MainWindow::on_cbFilter_currentIndexChanged(int index)
{
    // 切换滤波器类型
    // 0: 无滤波, 1: 移动平均, 2: 中值滤波, 3: IIR滤波
    Q_UNUSED(index)
    // 滤波器设置在applyFilter中处理
}

qreal MainWindow::applyFilter(qreal value)
{
    int filterType = ui->cbFilter->currentIndex();
    
    switch (filterType) {
    case 1:  // 移动平均滤波
        return movingAverageFilter(value);
    case 2:  // 中值滤波
        return medianFilter(value);
    case 3:  // IIR滤波
        return iirFilter(value);
    default: // 无滤波
        return value;
    }
}

qreal MainWindow::movingAverageFilter(qreal value)
{
    // 更新缓冲区
    for (int i = maBuffer.size() - 1; i > 0; --i) {
        maBuffer[i] = maBuffer[i - 1];
    }
    maBuffer[0] = value;
    
    // 计算平均值
    qreal sum = 0;
    for (const qreal &v : maBuffer) {
        sum += v;
    }
    
    return sum / maBuffer.size();
}

qreal MainWindow::medianFilter(qreal value)
{
    // 更新缓冲区
    for (int i = medianBuffer.size() - 1; i > 0; --i) {
        medianBuffer[i] = medianBuffer[i - 1];
    }
    medianBuffer[0] = value;
    
    // 复制并排序
    QVector<qreal> sortedBuffer = medianBuffer;
    std::sort(sortedBuffer.begin(), sortedBuffer.end());
    
    // 返回中值
    return sortedBuffer[sortedBuffer.size() / 2];
}

qreal MainWindow::iirFilter(qreal value)
{
    // 一阶IIR低通滤波器
    qreal filtered = iirAlpha * value + (1 - iirAlpha) * iirLastValue;
    iirLastValue = filtered;
    return filtered;
}

2. 心电图表类

2.1 心电图表头文件 (ecgchart.h)

cpp 复制代码
#ifndef ECGCHART_H
#define ECGCHART_H

#include <QObject>
#include <QChart>
#include <QLineSeries>
#include <QValueAxis>
#include <QTimer>
#include <QQueue>

QT_CHARTS_USE_NAMESPACE

class ECGChart : public QChart
{
    Q_OBJECT
    
public:
    explicit ECGChart(QGraphicsItem *parent = nullptr, 
                      Qt::WindowFlags wFlags = Qt::WindowFlags());
    ~ECGChart();
    
    // 添加数据点
    void addDataPoint(qreal value);
    
    // 清除数据
    void clear();
    
    // 暂停/继续
    void pause();
    void resume();
    
    // 设置显示参数
    void setDisplaySpeed(int speed);  // 速度百分比
    void setGain(qreal gain);         // 增益系数
    void setSampleRate(int rate);     // 采样率(Hz)
    
    // 设置显示范围
    void setYRange(qreal min, qreal max);
    
signals:
    void heartRateUpdated(int bpm);
    void dataPointAdded(qreal value);
    
private slots:
    void updateChart();
    
private:
    // 图表系列
    QLineSeries *ecgSeries;
    
    // 坐标轴
    QValueAxis *axisX;
    QValueAxis *axisY;
    
    // 数据缓冲区
    QQueue<QPointF> dataPoints;
    int maxDataPoints;
    
    // 显示参数
    qreal displayTime;    // 显示的时间范围(秒)
    qreal gain;           // 增益
    int sampleRate;       // 采样率
    
    // 控制参数
    bool isPaused;
    
    // 心率计算
    QVector<qreal> rPeaks;
    qreal lastRValue;
    qreal rThreshold;
    int rrInterval;
    int heartRate;
    
    // 定时器
    QTimer *updateTimer;
    
    // 初始化图表
    void initChart();
    
    // 检测R波
    bool detectRWave(qreal value);
    
    // 计算心率
    void calculateHeartRate();
    
    // 更新坐标轴范围
    void updateAxisRange();
};

#endif // ECGCHART_H

2.2 心电图表实现文件 (ecgchart.cpp)

cpp 复制代码
#include "ecgchart.h"
#include <QDebug>

ECGChart::ECGChart(QGraphicsItem *parent, Qt::WindowFlags wFlags)
    : QChart(QChart::ChartTypeCartesian, parent, wFlags)
    , displayTime(5.0)
    , gain(1.0)
    , sampleRate(200)
    , isPaused(false)
    , lastRValue(0.0)
    , rThreshold(100.0)
    , rrInterval(0)
    , heartRate(0)
{
    // 初始化图表
    initChart();
    
    // 设置最大数据点数
    maxDataPoints = displayTime * sampleRate;
    
    // 初始化定时器
    updateTimer = new QTimer(this);
    updateTimer->setInterval(50);  // 20Hz更新频率
    connect(updateTimer, &QTimer::timeout, this, &ECGChart::updateChart);
    updateTimer->start();
}

ECGChart::~ECGChart()
{
    updateTimer->stop();
}

void ECGChart::initChart()
{
    // 创建系列
    ecgSeries = new QLineSeries();
    ecgSeries->setName("心电波形");
    ecgSeries->setPen(QPen(Qt::green, 1.5));
    
    // 添加系列到图表
    addSeries(ecgSeries);
    
    // 创建坐标轴
    axisX = new QValueAxis();
    axisY = new QValueAxis();
    
    // 设置X轴
    axisX->setTitleText("时间 (秒)");
    axisX->setRange(0, displayTime);
    axisX->setLabelFormat("%.1f");
    axisX->setTickCount(6);
    axisX->setGridLineVisible(true);
    
    // 设置Y轴
    axisY->setTitleText("幅值 (mV)");
    axisY->setRange(-1.5, 1.5);
    axisY->setTickCount(7);
    axisY->setGridLineVisible(true);
    
    // 添加坐标轴
    addAxis(axisX, Qt::AlignBottom);
    addAxis(axisY, Qt::AlignLeft);
    
    // 关联系列和坐标轴
    ecgSeries->attachAxis(axisX);
    ecgSeries->attachAxis(axisY);
    
    // 设置图表属性
    setTitle("实时心电波形");
    setAnimationOptions(QChart::NoAnimation);
    legend()->setVisible(false);
    
    // 设置边距
    setMargins(QMargins(5, 5, 5, 5));
}

void ECGChart::addDataPoint(qreal value)
{
    if (isPaused) {
        return;
    }
    
    // 应用增益
    qreal scaledValue = value * gain;
    
    // 检测R波
    if (detectRWave(scaledValue)) {
        calculateHeartRate();
        emit heartRateUpdated(heartRate);
    }
    
    // 添加数据点
    qreal time = dataPoints.size() / static_cast<qreal>(sampleRate);
    dataPoints.enqueue(QPointF(time, scaledValue));
    
    // 保持数据点数不超过最大值
    if (dataPoints.size() > maxDataPoints) {
        dataPoints.dequeue();
    }
    
    // 发送信号
    emit dataPointAdded(scaledValue);
}

void ECGChart::clear()
{
    dataPoints.clear();
    ecgSeries->clear();
    rPeaks.clear();
    lastRValue = 0.0;
    rrInterval = 0;
    heartRate = 0;
    emit heartRateUpdated(0);
}

void ECGChart::pause()
{
    isPaused = true;
}

void ECGChart::resume()
{
    isPaused = false;
}

void ECGChart::setDisplaySpeed(int speed)
{
    // 速度百分比转换为显示时间
    displayTime = 0.5 + (speed / 100.0) * 9.5;  // 0.5-10秒
    maxDataPoints = displayTime * sampleRate;
    
    // 移除多余的数据点
    while (dataPoints.size() > maxDataPoints) {
        dataPoints.dequeue();
    }
}

void ECGChart::setGain(qreal newGain)
{
    gain = newGain;
    
    // 重新计算所有数据点
    if (!dataPoints.isEmpty()) {
        QQueue<QPointF> scaledPoints;
        for (const QPointF &point : dataPoints) {
            scaledPoints.enqueue(QPointF(point.x(), point.y() * gain));
        }
        dataPoints = scaledPoints;
    }
}

void ECGChart::setSampleRate(int rate)
{
    sampleRate = rate;
    maxDataPoints = displayTime * sampleRate;
    
    // 调整现有数据点的时间坐标
    if (!dataPoints.isEmpty()) {
        QQueue<QPointF> rescaledPoints;
        qint32 index = 0;
        for (const QPointF &point : dataPoints) {
            qreal time = index / static_cast<qreal>(sampleRate);
            rescaledPoints.enqueue(QPointF(time, point.y()));
            index++;
        }
        dataPoints = rescaledPoints;
    }
}

void ECGChart::setYRange(qreal min, qreal max)
{
    axisY->setRange(min, max);
}

void ECGChart::updateChart()
{
    if (isPaused || dataPoints.isEmpty()) {
        return;
    }
    
    // 更新系列数据
    ecgSeries->clear();
    
    // 计算时间偏移
    qreal currentTime = 0;
    if (!dataPoints.isEmpty()) {
        currentTime = dataPoints.last().x();
    }
    
    // 添加数据点到系列
    for (const QPointF &point : dataPoints) {
        // 调整X坐标,使其始终在显示范围内
        qreal x = point.x() - (currentTime - displayTime);
        if (x >= 0) {
            ecgSeries->append(x, point.y());
        }
    }
    
    // 更新坐标轴范围
    updateAxisRange();
}

void ECGChart::updateAxisRange()
{
    // 更新X轴范围
    qreal maxTime = displayTime;
    qreal minTime = 0;
    
    axisX->setRange(minTime, maxTime);
    
    // 自动调整Y轴范围
    if (!dataPoints.isEmpty()) {
        qreal minY = 1000;
        qreal maxY = -1000;
        
        for (const QPointF &point : dataPoints) {
            if (point.y() < minY) minY = point.y();
            if (point.y() > maxY) maxY = point.y();
        }
        
        // 添加一些边距
        qreal margin = (maxY - minY) * 0.1;
        if (margin < 0.1) margin = 0.1;
        
        axisY->setRange(minY - margin, maxY + margin);
    }
}

bool ECGChart::detectRWave(qreal value)
{
    static bool isRising = false;
    static int samplesSinceLastR = 0;
    
    samplesSinceLastR++;
    
    // 简单R波检测算法
    if (value > rThreshold && value > lastRValue) {
        isRising = true;
    } 
    else if (isRising && value < lastRValue && value > rThreshold * 0.8) {
        // 检测到峰值
        isRising = false;
        
        // 避免检测到太近的R波
        if (samplesSinceLastR > sampleRate * 0.3) {  // 最少300ms间隔
            rPeaks.append(value);
            rrInterval = samplesSinceLastR;
            samplesSinceLastR = 0;
            
            // 保持最近10个R波
            if (rPeaks.size() > 10) {
                rPeaks.removeFirst();
            }
            
            lastRValue = value;
            return true;
        }
    }
    
    lastRValue = value;
    return false;
}

void ECGChart::calculateHeartRate()
{
    if (rPeaks.size() < 2 || rrInterval == 0) {
        heartRate = 0;
        return;
    }
    
    // 计算平均RR间期
    qreal avgRR = 0;
    for (int i = 1; i < rPeaks.size(); i++) {
        // 这里应该使用实际的时间差
        // 简化:使用采样点间隔
        avgRR += 1.0;  // 实际应该用时间差
    }
    avgRR /= (rPeaks.size() - 1);
    
    // 计算心率 (BPM = 60 / RR间期(秒))
    if (avgRR > 0) {
        heartRate = static_cast<int>(60.0 / (avgRR / sampleRate));
        
        // 限制心率在合理范围内
        if (heartRate < 30) heartRate = 30;
        if (heartRate > 200) heartRate = 200;
    }
}

3. 串口读取器类

3.1 串口读取器头文件 (serialportreader.h)

cpp 复制代码
#ifndef SERIALPORTREADER_H
#define SERIALPORTREADER_H

#include <QObject>
#include <QSerialPort>
#include <QByteArray>

class SerialPortReader : public QObject
{
    Q_OBJECT
    
public:
    explicit SerialPortReader(QObject *parent = nullptr);
    ~SerialPortReader();
    
    // 打开串口
    bool openPort(const QString &portName, 
                  qint32 baudRate = QSerialPort::Baud115200,
                  QSerialPort::DataBits dataBits = QSerialPort::Data8,
                  QSerialPort::Parity parity = QSerialPort::NoParity,
                  QSerialPort::StopBits stopBits = QSerialPort::OneStop,
                  QSerialPort::FlowControl flowControl = QSerialPort::NoFlowControl);
    
    // 关闭串口
    void closePort();
    
    // 检查串口是否打开
    bool isOpen() const;
    
    // 发送数据
    qint64 writeData(const QByteArray &data);
    
signals:
    // 数据接收信号
    void dataReceived(const QByteArray &data);
    
    // 错误信号
    void errorOccurred(const QString &errorString);
    
    // 端口状态信号
    void portOpened();
    void portClosed();
    
private slots:
    void handleReadyRead();
    void handleError(QSerialPort::SerialPortError error);
    
private:
    QSerialPort *serialPort;
    QByteArray receiveBuffer;
    
    // 数据包解析
    void parseDataPackets();
};

#endif // SERIALPORTREADER_H

4. 界面文件 (mainwindow.ui)

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
 <class>MainWindow</class>
 <widget class="QMainWindow" name="MainWindow">
  <property name="geometry">
   <rect>
    <x>0</x>
    <y>0</y>
    <width>1200</width>
    <height>800</height>
   </rect>
  </property>
  <property name="windowTitle">
   <string>心电波形实时显示系统</string>
  </property>
  <widget class="QWidget" name="centralwidget">
   <layout class="QVBoxLayout" name="verticalLayout">
    <item>
     <layout class="QHBoxLayout" name="horizontalLayout_2">
      <item>
       <widget class="QGroupBox" name="groupBox">
        <property name="title">
         <string>串口设置</string>
        </property>
        <layout class="QGridLayout" name="gridLayout">
         <item row="0" column="0">
          <widget class="QLabel" name="label">
           <property name="text">
            <string>串口:</string>
           </property>
          </widget>
         </item>
         <item row="0" column="1">
          <widget class="QComboBox" name="cbPortName"/>
         </item>
         <item row="0" column="2">
          <widget class="QPushButton" name="btnRefreshPorts">
           <property name="text">
            <string>刷新</string>
           </property>
          </widget>
         </item>
         <item row="1" column="0">
          <widget class="QLabel" name="label_2">
           <property name="text">
            <string>波特率:</string>
           </property>
          </widget>
         </item>
         <item row="1" column="1">
          <widget class="QComboBox" name="cbBaudRate">
           <item>
            <property name="text">
             <string>9600</string>
            </property>
           </item>
           <item>
            <property name="text">
             <string>19200</string>
            </property>
           </item>
           <item>
            <property name="text">
             <string>38400</string>
            </property>
           </item>
           <item>
            <property name="text">
             <string>57600</string>
            </property>
           </item>
           <item>
            <property name="text">
             <string>115200</string>
            </property>
           </item>
          </widget>
         </item>
         <item row="1" column="2">
          <widget class="QPushButton" name="btnOpenPort">
           <property name="text">
            <string>打开串口</string>
           </property>
          </widget>
         </item>
         <item row="2" column="0">
          <widget class="QLabel" name="label_3">
           <property name="text">
            <string>数据位:</string>
           </property>
          </widget>
         </item>
         <item row="2" column="1">
          <widget class="QComboBox" name="cbDataBits">
           <item>
            <property name="text">
             <string>5</string>
            </property>
           </item>
           <item>
            <property name="text">
             <string>6</string>
            </property>
           </item>
           <item>
            <property name="text">
             <string>7</string>
            </property>
           </item>
           <item>
            <property name="text">
             <string>8</string>
            </property>
           </item>
          </widget>
         </item>
         <item row="2" column="2">
          <widget class="QPushButton" name="btnClosePort">
           <property name="text">
            <string>关闭串口</string>
           </property>
          </widget>
         </item>
         <item row="3" column="0">
          <widget class="QLabel" name="label_4">
           <property name="text">
            <string>校验位:</string>
           </property>
          </widget>
         </item>
         <item row="3" column="1">
          <widget class="QComboBox" name="cbParity">
           <item>
            <property name="text">
             <string>None</string>
            </property>
           </item>
           <item>
            <property name="text">
             <string>Even</string>
            </property>
           </item>
           <item>
            <property name="text">
             <string>Odd</string>
            </property>
           </item>
           <item>
            <property name="text">
             <string>Space</string>
            </property>
           </item>
           <item>
            <property name="text">
             <string>Mark</string>
            </property>
           </item>
          </widget>
         </item>
         <item row="3" column="2">
          <widget class="QPushButton" name="btnStartRecord">
           <property name="text">
            <string>开始记录</string>
           </property>
          </widget>
         </item>
         <item row="4" column="0">
          <widget class="QLabel" name="label_5">
           <property name="text">
            <string>停止位:</string>
           </property>
          </widget>
         </item>
         <item row="4" column="1">
          <widget class="QComboBox" name="cbStopBits">
           <item>
            <property name="text">
             <string>1</string>
            </property>
           </item>
           <item>
            <property name="text">
             <string>1.5</string>
            </property>
           </item>
           <item>
            <property name="text">
             <string>2</string>
            </property>
           </item>
          </widget>
         </item>
         <item row="4" column="2">
          <widget class="QPushButton" name="btnStopRecord">
           <property name="text">
            <string>停止记录</string>
           </property>
          </widget>
         </item>
         <item row="5" column="0">
          <widget class="QLabel" name="label_6">
           <property name="text">
            <string>流控制:</string>
           </property>
          </widget>
         </item>
         <item row="5" column="1">
          <widget class="QComboBox" name="cbFlowControl">
           <item>
            <property name="text">
             <string>None</string>
            </property>
           </item>
           <item>
            <property name="text">
             <string>RTS/CTS</string>
            </property>
           </item>
           <item>
            <property name="text">
             <string>XON/XOFF</string>
            </property>
           </item>
          </widget>
         </item>
         <item row="5" column="2">
          <widget class="QPushButton" name="btnPause">
           <property name="text">
            <string>暂停</string>
           </property>
          </widget>
         </item>
        </layout>
       </widget>
      </item>
      <item>
       <widget class="QGroupBox" name="groupBox_2">
        <property name="title">
         <string>显示控制</string>
        </property>
        <layout class="QVBoxLayout" name="verticalLayout_3">
         <item>
          <widget class="QLabel" name="label_7">
           <property name="text">
            <string>显示速度:</string>
           </property>
          </widget>
         </item>
         <item>
          <widget class="QSlider" name="sliderSpeed">
           <property name="minimum">
            <number>0</number>
           </property>
           <property name="maximum">
            <number>100</number>
           </property>
           <property name="value">
            <number>50</number>
           </property>
           <property name="orientation">
            <enum>Qt::Horizontal</enum>
           </property>
          </widget>
         </item>
         <item>
          <widget class="QLabel" name="lblSpeed">
           <property name="text">
            <string>2.5 s</string>
           </property>
          </widget>
         </item>
         <item>
          <spacer name="verticalSpacer">
           <property name="orientation">
            <enum>Qt::Vertical</enum>
           </property>
           <property name="sizeHint" stdset="0">
            <size>
             <width>20</width>
             <height>10</height>
            </size>
           </property>
          </spacer>
         </item>
         <item>
          <widget class="QLabel" name="label_8">
           <property name="text">
            <string>增益:</string>
           </property>
          </widget>
         </item>
         <item>
          <widget class="QSlider" name="sliderGain">
           <property name="minimum">
            <number>10</number>
           </property>
           <property name="maximum">
            <number>500</number>
           </property>
           <property name="value">
            <number>100</number>
           </property>
           <property name="orientation">
            <enum>Qt::Horizontal</enum>
           </property>
          </widget>
         </item>
         <item>
          <widget class="QLabel" name="lblGain">
           <property name="text">
            <string>x1.00</string>
           </property>
          </widget>
         </item>
         <item>
          <spacer name="verticalSpacer_2">
           <property name="orientation">
            <enum>Qt::Vertical</enum>
           </property>
           <property name="sizeHint" stdset="0">
            <size>
             <width>20</width>
             <height>10</height>
            </size>
           </property>
          </spacer>
         </item>
         <item>
          <widget class="QLabel" name="label_9">
           <property name="text">
            <string>滤波器:</string>
           </property>
          </widget>
         </item>
         <item>
          <widget class="QComboBox" name="cbFilter">
           <item>
            <property name="text">
             <string>无滤波</string>
            </property>
           </item>
           <item>
            <property name="text">
             <string>移动平均</string>
            </property>
           </item>
           <item>
            <property name="text">
             <string>中值滤波</string>
            </property>
           </item>
           <item>
            <property name="text">
             <string>IIR滤波</string>
            </property>
           </item>
          </widget>
         </item>
         <item>
          <spacer name="verticalSpacer_3">
           <property name="orientation">
            <enum>Qt::Vertical</enum>
           </property>
           <property name="sizeHint" stdset="0">
            <size>
             <width>20</width>
             <height>10</height>
            </size>
           </property>
          </spacer>
         </item>
         <item>
          <widget class="QPushButton" name="btnClear">
           <property name="text">
            <string>清除波形</string>
           </property>
          </widget>
         </item>
        </layout>
       </widget>
      </item>
      <item>
       <widget class="QGroupBox" name="groupBox_3">
        <property name="title">
         <string>心电参数</string>
        </property>
        <layout class="QVBoxLayout" name="verticalLayout_2">
         <item>
          <widget class="QLabel" name="label_10">
           <property name="text">
            <string>心率:</string>
           </property>
          </widget>
         </item>
         <item>
          <widget class="QLabel" name="lblHeartRate">
           <property name="text">
            <string>--</string>
           </property>
           <property name="alignment">
            <set>Qt::AlignCenter</set>
           </property>
          </widget>
         </item>
         <item>
          <widget class="QLabel" name="label_12">
           <property name="text">
            <string>BPM</string>
           </property>
           <property name="alignment">
            <set>Qt::AlignCenter</set>
           </property>
          </widget>
         </item>
         <item>
          <spacer name="verticalSpacer_4">
           <property name="orientation">
            <enum>Qt::Vertical</enum>
           </property>
           <property name="sizeHint" stdset="0">
            <size>
             <width>20</width>
             <height>40</height>
            </size>
           </property>
          </spacer>
         </item>
         <item>
          <widget class="QLabel" name="label_11">
           <property name="text">
            <string>采样率:</string>
           </property>
          </widget>
         </item>
         <item>
          <widget class="QLabel" name="lblSampleRate">
           <property name="text">
            <string>200 Hz</string>
           </property>
           <property name="alignment">
            <set>Qt::AlignCenter</set>
           </property>
          </widget>
         </item>
         <item>
          <spacer name="verticalSpacer_5">
           <property name="orientation">
            <enum>Qt::Vertical</enum>
           </property>
           <property name="sizeHint" stdset="0">
            <size>
             <width>20</width>
             <height>40</height>
            </size>
           </property>
          </spacer>
         </item>
         <item>
          <widget class="QLabel" name="label_13">
           <property name="text">
            <string>数据点:</string>
           </property>
          </widget>
         </item>
         <item>
          <widget class="QLabel" name="lblDataPoints">
           <property name="text">
            <string>0</string>
           </property>
           <property name="alignment">
            <set>Qt::AlignCenter</set>
           </property>
          </widget>
         </item>
        </layout>
       </widget>
      </item>
     </layout>
    </item>
    <item>
     <layout class="QVBoxLayout" name="chartLayout"/>
    </item>
   </layout>
  </widget>
  <widget class="QStatusBar" name="statusbar"/>
 </widget>
 <resources/>
 <connections/>
</ui>

5. 主程序 (main.cpp)

cpp 复制代码
#include "mainwindow.h"
#include <QApplication>
#include <QStyleFactory>

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    
    // 设置应用程序信息
    QApplication::setApplicationName("ECG Waveform Monitor");
    QApplication::setOrganizationName("Medical Systems");
    QApplication::setApplicationVersion("1.0.0");
    
    // 设置样式
    qApp->setStyle(QStyleFactory::create("Fusion"));
    
    // 设置调色板
    QPalette palette;
    palette.setColor(QPalette::Window, QColor(53, 53, 53));
    palette.setColor(QPalette::WindowText, Qt::white);
    palette.setColor(QPalette::Base, QColor(25, 25, 25));
    palette.setColor(QPalette::AlternateBase, QColor(53, 53, 53));
    palette.setColor(QPalette::ToolTipBase, Qt::white);
    palette.setColor(QPalette::ToolTipText, Qt::white);
    palette.setColor(QPalette::Text, Qt::white);
    palette.setColor(QPalette::Button, QColor(53, 53, 53));
    palette.setColor(QPalette::ButtonText, Qt::white);
    palette.setColor(QPalette::BrightText, Qt::red);
    palette.setColor(QPalette::Link, QColor(42, 130, 218));
    palette.setColor(QPalette::Highlight, QColor(42, 130, 218));
    palette.setColor(QPalette::HighlightedText, Qt::black);
    qApp->setPalette(palette);
    
    MainWindow w;
    w.show();
    
    return a.exec();
}

6. STM32单片机端代码示例

c 复制代码
// STM32端的心电数据发送代码
#include "stm32f1xx_hal.h"
#include <string.h>
#include <stdio.h>

// ADC缓冲区
#define ADC_BUFFER_SIZE 200
uint16_t adc_buffer[ADC_BUFFER_SIZE];

// 心电数据包结构
typedef struct {
    uint8_t start_flag[2];  // 起始标志: 0xAA 0x55
    uint16_t ecg_value;     // ECG值
    uint16_t heart_rate;    // 心率
    uint8_t checksum;       // 校验和
} ECG_Packet;

// 计算校验和
uint8_t calculate_checksum(const ECG_Packet* packet) {
    uint8_t checksum = 0;
    uint8_t* data = (uint8_t*)packet;
    
    for(int i = 0; i < sizeof(ECG_Packet) - 1; i++) {
        checksum ^= data[i];
    }
    
    return checksum;
}

// 发送心电数据包
void send_ecg_packet(UART_HandleTypeDef* huart, uint16_t ecg_value, uint16_t heart_rate) {
    ECG_Packet packet;
    
    // 设置起始标志
    packet.start_flag[0] = 0xAA;
    packet.start_flag[1] = 0x55;
    
    // 设置数据
    packet.ecg_value = ecg_value;
    packet.heart_rate = heart_rate;
    
    // 计算校验和
    packet.checksum = calculate_checksum(&packet);
    
    // 发送数据包
    HAL_UART_Transmit(huart, (uint8_t*)&packet, sizeof(ECG_Packet), 100);
}

// 模拟心电数据生成
uint16_t generate_ecg_signal(uint32_t time_ms) {
    static uint32_t last_time = 0;
    static uint16_t ecg_value = 2048;  // 中间值
    
    // 模拟心电波形
    uint32_t phase = time_ms % 1000;  // 1秒周期
    
    if(phase < 50) {
        // P波
        ecg_value = 2100 + (phase * 2);
    } else if(phase >= 50 && phase < 100) {
        // PR段
        ecg_value = 2200;
    } else if(phase >= 100 && phase < 150) {
        // QRS波群
        ecg_value = 2400 - ((phase - 100) * 8);
    } else if(phase >= 150 && phase < 180) {
        // ST段
        ecg_value = 2000;
    } else if(phase >= 180 && phase < 230) {
        // T波
        ecg_value = 2100 + ((phase - 180) * 2);
    } else {
        // 基线
        ecg_value = 2048;
    }
    
    // 添加噪声
    int16_t noise = (rand() % 100) - 50;  // -50到+50的噪声
    ecg_value += noise;
    
    // 限制范围
    if(ecg_value > 4095) ecg_value = 4095;
    if(ecg_value < 0) ecg_value = 0;
    
    return ecg_value;
}

// 主循环
int main(void) {
    HAL_Init();
    SystemClock_Config();
    
    // 初始化外设
    MX_GPIO_Init();
    MX_ADC1_Init();
    MX_USART1_UART_Init();
    
    // 初始化ADC DMA
    HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, ADC_BUFFER_SIZE);
    
    uint32_t last_send_time = 0;
    uint16_t heart_rate = 72;  // 模拟心率
    
    while(1) {
        uint32_t current_time = HAL_GetTick();
        
        // 每20ms发送一次数据 (50Hz)
        if(current_time - last_send_time >= 20) {
            // 读取ADC值
            uint16_t adc_value = adc_buffer[0];  // 使用第一个采样值
            
            // 或者生成模拟信号
            // adc_value = generate_ecg_signal(current_time);
            
            // 发送数据
            send_ecg_packet(&huart1, adc_value, heart_rate);
            
            last_send_time = current_time;
        }
        
        // 模拟心率变化
        if(current_time % 10000 == 0) {  // 每10秒变化一次
            heart_rate = 60 + (rand() % 40);  // 60-100 BPM
        }
    }
}

参考代码 利用QT读取单片机发送的串口数据 www.youwenfan.com/contentcsv/71024.html

7. 编译和运行说明

7.1 环境要求

  • QT 5.12 或更高版本
  • QT Charts 模块
  • QT SerialPort 模块
  • C++11 兼容编译器

7.2 编译步骤

  1. 创建QT Widgets Application项目
  2. 在.pro文件中添加QT Charts和SerialPort模块
  3. 将上述代码文件添加到项目中
  4. 配置UI文件
  5. 编译运行

7.3 使用说明

  1. 连接STM32开发板到电脑串口
  2. 运行QT程序
  3. 选择正确的串口号和波特率
  4. 点击"打开串口"按钮
  5. 调节显示参数和滤波器设置
  6. 点击"开始记录"保存数据
  7. 观察实时心电波形和心率显示

8. 功能特点

  1. 实时显示:实时显示心电波形,支持暂停/继续
  2. 多滤波器:提供多种滤波器选项
  3. 参数调节:可调节显示速度和增益
  4. 数据记录:支持数据记录为CSV格式
  5. 心率检测:简单的R波检测和心率计算
  6. 串口通信:支持多种串口参数设置
  7. 美观界面:采用暗色主题,保护眼睛

9. 注意事项

  1. 确保单片机发送的数据格式与程序解析格式一致
  2. 根据实际心电信号调整增益和滤波器参数
  3. 采样率需要与单片机端匹配
  4. 长时间运行注意内存管理
  5. 实际应用中需要更精确的R波检测算法
相关推荐
之歆13 小时前
DAY_12JavaScript DOM 完全指南(三):高级工程篇
开发语言·前端·javascript·ecmascript
invicinble13 小时前
java数组相关的信息量
java·开发语言·python
牧瀬クリスだ13 小时前
Java线程——从创建第一个线程到休眠线程
java·开发语言
我命由我1234513 小时前
Android 开发问题:TextView 内容超过宽度时,默认不会换行
android·开发语言·java-ee·android studio·android jetpack·android-studio·android runtime
计算机安禾13 小时前
【c++面向对象编程】第36篇:析构函数应永远不抛出异常——原因与最佳实践
开发语言·c++
一条泥憨鱼13 小时前
【Java 进阶】LinkedHashMap 与 TreeMap
java·开发语言·数据结构·笔记·后端·学习
凤山老林13 小时前
63-Java LinkedList(链表)
java·开发语言·链表
恣艺13 小时前
用Go从零实现一个高性能KV存储引擎:B+Tree索引、WAL持久化、LRU缓存的工程实践
开发语言·数据库·redis·缓存·golang
kkeeper~1 天前
0基础C语言积跬步之深入理解指针(5下)
c语言·开发语言