classdef ECG_Monitor_System < handle
properties
% 通信相关
serialPort % 串口对象
isConnected = false
comPort = 'COM1' % 默认串口
baudRate = 115200 % OEM协议常用波特率
dataBits = 8 % 数据位
parity = 'none' % 校验位
stopBits = 1 % 停止位
% 协议栈属性
frameBuffer = []; % 帧缓冲区
inFrame = false; % 帧接收状态标志
frameLength = 0; % 当前帧长度
expectedLength = 8; % CAN标准帧长度
% 会话管理
currentSession = 0; % 当前会话状态(0=默认,1=扩展诊断)
securityLevel = 0; % 安全访问级别(0=未授权,1=编程,2=安全)
% 数据管理
ecgData = zeros(12, 10000);
filteredData = zeros(12, 10000);
timeStamps = zeros(1, 10000);
sampleRate = 1000;
currentIndex = 1;
dataLength = 0;
isRecording = false;
timerObj
maxDataPoints = 10000 % 最大数据点数
numLeads = 12 % 导联数量
activeLead = 2 % 默认显示II导联
% 滤波参数
highPassFreq = 0.5;
lowPassFreq = 100;
notchFreq = 50; % 工频陷波
% 分析参数
heartRate = 0;
rPeakPositions = [];
qtInterval = 0;
stSegment = 0;
% 心电特征参数
qtIntervals = [] % QT间期数组(秒)
stSegments = [] % ST段偏移数组(mV)
avgQT = 0 % 平均QT间期
avgST = 0 % 平均ST偏移
% GUI组件
fig % 主控制窗口
overviewFig % 总览窗口
singleLeadFigs = gobjects(12, 1); % 单导联窗口
leadPlots = gobjects(12, 1); % 单导联绘图句柄
overviewPlots = gobjects(12, 1); % 总览窗口绘图句柄
leadNames = {'I', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6'};
% 控制面板组件
controlPanel
highPassEdit
lowPassEdit
notchCheckbox
statusText
heartRateText
recordButton
saveButton
leadSelect
dataSlider
positionText
% 频谱分析
spectrumAx
% 报告生成
patientInfo = struct('name', '未知', 'gender', '未知', 'age', 0, 'id', '');
end
methods
function app = ECG_Monitor_System()
app.createMainGUI();
app.createOverviewWindow();
app.initializeSystem();
end
function createMainGUI(app)
% 创建主控制窗口
app.fig = figure('Name', '心电图监测系统 - 控制面板', 'NumberTitle', 'off',...
'Position', [100, 100, 800, 600], 'CloseRequestFcn', @app.onClose,...
'MenuBar', 'none', 'ToolBar', 'none');
% 创建菜单栏
app.createMenus();
% 创建控制面板
app.createControlPanel();
end
function createOverviewWindow(app)
% 创建总览窗口
app.overviewFig = figure('Name', '心电图总览', 'NumberTitle', 'off',...
'Position', [950, 100, 1200, 800], 'CloseRequestFcn', @app.onOverviewClose);
% 创建12导联网格布局
for i = 1:12
row = ceil(i/3);
col = mod(i-1,3)+1;
ax = subplot(4, 3, i, 'Parent', app.overviewFig);
app.overviewPlots(i) = plot(ax, nan, nan, 'b-');
title(ax, app.leadNames{i});
xlabel(ax, '时间(s)');
ylabel(ax, '幅度(mV)');
grid(ax, 'on');
end
end
function createSingleLeadWindow(app, leadIndex)
% 创建单导联窗口
if ishandle(app.singleLeadFigs(leadIndex))
figure(app.singleLeadFigs(leadIndex)); % 激活现有窗口
return;
end
figName = sprintf('导联 %s 详细视图', app.leadNames{leadIndex});
app.singleLeadFigs(leadIndex) = figure('Name', figName, 'NumberTitle', 'off',...
'Position', [100 + 50*leadIndex, 100 + 50*leadIndex, 600, 400],...
'CloseRequestFcn', @(src,evt) app.onSingleLeadClose(leadIndex));
ax = axes('Parent', app.singleLeadFigs(leadIndex), 'Position', [0.1, 0.15, 0.85, 0.75]);
app.leadPlots(leadIndex) = plot(ax, nan, nan, 'b-');
title(ax, sprintf('导联 %s - 详细视图', app.leadNames{leadIndex}));
xlabel(ax, '时间(s)');
ylabel(ax, '幅度(mV)');
grid(ax, 'on');
% 添加导联特定控制
uicontrol('Parent', app.singleLeadFigs(leadIndex), 'Style', 'pushbutton',...
'String', '检测R波', 'Position', [20, 10, 80, 25],...
'Callback', @(src,evt) app.detectRPeaksForLead(leadIndex));
uicontrol('Parent', app.singleLeadFigs(leadIndex), 'Style', 'pushbutton',...
'String', '频谱分析', 'Position', [120, 10, 80, 25],...
'Callback', @(src,evt) app.showSpectrumForLead(leadIndex));
end
function createMenus(app)
% 文件菜单
fileMenu = uimenu(app.fig, 'Text', '文件');
uimenu(fileMenu, 'Text', '加载数据', 'MenuSelectedFcn', @app.loadDataFile);
uimenu(fileMenu, 'Text', '保存数据', 'MenuSelectedFcn', @app.saveData);
uimenu(fileMenu, 'Text', '生成报告', 'MenuSelectedFcn', @app.generateReport);
uimenu(fileMenu, 'Text', '退出', 'Separator', 'on', 'MenuSelectedFcn', @app.onClose);
% 通信菜单
comMenu = uimenu(app.fig, 'Text', '通信');
uimenu(comMenu, 'Text', '连接设备', 'MenuSelectedFcn', @app.connectDevice);
uimenu(comMenu, 'Text', '断开连接', 'MenuSelectedFcn', @app.disconnect);
uimenu(comMenu, 'Text', '启动诊断会话', 'MenuSelectedFcn', @(src,evt) app.startSession(1));
% 视图菜单
viewMenu = uimenu(app.fig, 'Text', '视图');
uimenu(viewMenu, 'Text', '显示总览窗口', 'MenuSelectedFcn', @app.showOverviewWindow);
uimenu(viewMenu, 'Text', '显示所有导联窗口', 'MenuSelectedFcn', @app.showAllLeadWindows);
uimenu(viewMenu, 'Text', '关闭所有导联窗口', 'MenuSelectedFcn', @app.closeAllLeadWindows);
% 设置菜单
settingsMenu = uimenu(app.fig, 'Text', '设置');
uimenu(settingsMenu, 'Text', '通信设置', 'MenuSelectedFcn', @app.setCommunication);
uimenu(settingsMenu, 'Text', '患者信息', 'MenuSelectedFcn', @app.setPatientInfo);
uimenu(settingsMenu, 'Text', '分析参数', 'MenuSelectedFcn', @app.setAnalysisParams);
end
function createControlPanel(app)
% 创建控制面板
app.controlPanel = uipanel('Parent', app.fig, 'Title', '控制面板',...
'Position', [0.05, 0.05, 0.9, 0.9]);
% 通信控制
uicontrol('Parent', app.controlPanel, 'Style', 'pushbutton',...
'String', '连接设备', 'Position', [20, 520, 100, 30],...
'Callback', @app.connectDevice);
uicontrol('Parent', app.controlPanel, 'Style', 'pushbutton',...
'String', '断开连接', 'Position', [140, 520, 100, 30],...
'Callback', @app.disconnect);
app.statusText = uicontrol('Parent', app.controlPanel, 'Style', 'text',...
'String', '状态: 未连接', 'Position', [260, 520, 300, 30],...
'HorizontalAlignment', 'left', 'FontSize', 10);
% 会话状态
uicontrol('Parent', app.controlPanel, 'Style', 'text',...
'String', '会话: 默认', 'Position', [580, 520, 100, 30],...
'HorizontalAlignment', 'left', 'FontSize', 10);
% 滤波设置
uicontrol('Parent', app.controlPanel, 'Style', 'text', 'String', '高通滤波(Hz):',...
'Position', [20, 470, 100, 25], 'FontSize', 10);
app.highPassEdit = uicontrol('Parent', app.controlPanel, 'Style', 'edit',...
'String', '0.5', 'Position', [130, 470, 60, 25], 'FontSize', 10);
uicontrol('Parent', app.controlPanel, 'Style', 'text', 'String', '低通滤波(Hz):',...
'Position', [20, 440, 100, 25], 'FontSize', 10);
app.lowPassEdit = uicontrol('Parent', app.controlPanel, 'Style', 'edit',...
'String', '100', 'Position', [130, 440, 60, 25], 'FontSize', 10);
uicontrol('Parent', app.controlPanel, 'Style', 'pushbutton', 'String', '应用滤波',...
'Position', [200, 450, 80, 30], 'Callback', @app.applyFilters);
% 工频陷波
app.notchCheckbox = uicontrol('Parent', app.controlPanel, 'Style', 'checkbox',...
'String', '50Hz陷波', 'Position', [300, 450, 100, 25], 'Value', 1, 'FontSize', 10);
% 数据记录
app.recordButton = uicontrol('Parent', app.controlPanel, 'Style', 'togglebutton',...
'String', '开始记录', 'Position', [20, 380, 100, 30],...
'Callback', @app.toggleRecording);
app.saveButton = uicontrol('Parent', app.controlPanel, 'Style', 'pushbutton',...
'String', '保存数据', 'Position', [140, 380, 100, 30],...
'Callback', @app.saveData);
% 导联选择
uicontrol('Parent', app.controlPanel, 'Style', 'text',...
'String', '导联选择:', 'Position', [20, 340, 80, 25], 'FontSize', 10);
app.leadSelect = uicontrol('Parent', app.controlPanel, 'Style', 'popup',...
'String', app.leadNames, 'Position', [110, 340, 100, 25],...
'Callback', @app.onLeadSelected, 'FontSize', 10);
% 打开导联窗口按钮
uicontrol('Parent', app.controlPanel, 'Style', 'pushbutton',...
'String', '打开导联窗口', 'Position', [230, 340, 120, 30],...
'Callback', @app.openSelectedLeadWindow);
% 心率显示
uicontrol('Parent', app.controlPanel, 'Style', 'text',...
'String', '心率:', 'Position', [20, 300, 50, 25], 'FontSize', 10);
app.heartRateText = uicontrol('Parent', app.controlPanel, 'Style', 'text',...
'String', '-- bpm', 'Position', [80, 300, 80, 25], 'FontSize', 10);
% 数据导航
uicontrol('Parent', app.controlPanel, 'Style', 'text',...
'String', '数据位置:', 'Position', [20, 260, 80, 25], 'FontSize', 10);
app.dataSlider = uicontrol('Parent', app.controlPanel, 'Style', 'slider',...
'Position', [110, 260, 300, 25], 'Callback', @app.sliderMoved,...
'Enable', 'off');
app.positionText = uicontrol('Parent', app.controlPanel, 'Style', 'text',...
'String', '0/0', 'Position', [420, 260, 80, 25], 'FontSize', 10);
% 视图控制按钮
uicontrol('Parent', app.controlPanel, 'Style', 'pushbutton',...
'String', '显示总览窗口', 'Position', [20, 200, 120, 30],...
'Callback', @app.showOverviewWindow);
uicontrol('Parent', app.controlPanel, 'Style', 'pushbutton',...
'String', '显示所有导联窗口', 'Position', [160, 200, 150, 30],...
'Callback', @app.showAllLeadWindows);
uicontrol('Parent', app.controlPanel, 'Style', 'pushbutton',...
'String', '关闭所有导联窗口', 'Position', [330, 200, 150, 30],...
'Callback', @app.closeAllLeadWindows);
% 频谱分析
spectrumPanel = uipanel('Parent', app.controlPanel, 'Title', '频谱分析',...
'Position', [0.05, 0.05, 0.9, 0.15]);
app.spectrumAx = axes('Parent', spectrumPanel, 'Position', [0.1, 0.2, 0.85, 0.7]);
title(app.spectrumAx, 'ECG信号频谱');
xlabel(app.spectrumAx, '频率(Hz)');
ylabel(app.spectrumAx, '幅度(dB)');
grid(app.spectrumAx, 'on');
end
function initializeSystem(app)
% 初始化系统参数
app.sampleRate = 1000;
app.dataLength = 0;
app.currentIndex = 1;
app.isRecording = false;
% 初始化串口参数(默认值需符合OEM协议)
app.comPort = 'COM1';
app.baudRate = 115200;
app.dataBits = 8;
app.parity = 'none';
app.stopBits = 1;
% 设置默认患者信息
app.patientInfo = struct(...
'name', '张三', ...
'gender', '男', ...
'age', 45, ...
'id', 'P2023001', ...
'date', datestr(now, 'yyyy-mm-dd'));
end
%% 窗口管理功能
function showOverviewWindow(app, ~, ~)
% 显示总览窗口
if ishandle(app.overviewFig)
figure(app.overviewFig);
else
app.createOverviewWindow();
end
end
function showAllLeadWindows(app, ~, ~)
% 显示所有导联窗口
for i = 1:12
app.createSingleLeadWindow(i);
end
end
function closeAllLeadWindows(app, ~, ~)
% 关闭所有导联窗口
for i = 1:12
if ishandle(app.singleLeadFigs(i))
delete(app.singleLeadFigs(i));
end
end
end
function openSelectedLeadWindow(app, ~, ~)
% 打开选中的导联窗口
leadIdx = get(app.leadSelect, 'Value');
app.createSingleLeadWindow(leadIdx);
end
function onLeadSelected(app, src, ~)
% 导联选择回调
leadIdx = get(src, 'Value');
app.updateSpectrumForLead(leadIdx);
end
function onOverviewClose(app, ~, ~)
% 总览窗口关闭回调
delete(app.overviewFig);
app.overviewFig = gobjects(1);
end
function onSingleLeadClose(app, leadIndex)
% 单导联窗口关闭回调
delete(app.singleLeadFigs(leadIndex));
app.singleLeadFigs(leadIndex) = gobjects(1);
end
%% 通信协议实现
function setCommunication(app, ~, ~)
% 串口设置对话框
prompt = {'串口号:', '波特率:', '数据位:', '校验位:', '停止位:'};
dlgtitle = '串口设置';
dims = [1 20];
definput = {app.comPort, num2str(app.baudRate), num2str(app.dataBits), ...
app.parity, num2str(app.stopBits)};
answer = inputdlg(prompt, dlgtitle, dims, definput);
if ~isempty(answer)
app.comPort = answer{1};
app.baudRate = str2double(answer{2});
app.dataBits = str2double(answer{3});
app.parity = answer{4};
app.stopBits = str2double(answer{5});
% 验证波特率是否合法(示例:仅允许常见波特率)
validBaudRates = [9600, 19200, 38400, 57600, 115200, 230400];
if ~any(app.baudRate == validBaudRates)
app.baudRate = 115200; % 默认恢复为常用值
warndlg('波特率设置不合法,已恢复为115200', '设置警告');
end
set(app.statusText, 'String', '串口设置已更新');
end
end
function connectDevice(app, ~, ~)
try
% 关闭现有连接
if ~isempty(app.serialPort) && isvalid(app.serialPort)
fclose(app.serialPort);
delete(app.serialPort);
end
% 创建串口对象(根据OEM协议配置参数)
app.serialPort = serialport(app.comPort, app.baudRate, ...
'DataBits', app.dataBits, ...
'Parity', app.parity, ...
'StopBits', app.stopBits);
% 设置基于字节的回调机制(符合OEM协议)
configureCallback(app.serialPort, "byte", 1, ...
@(src, ~) app.frameProcessor(src));
% 初始化数据缓冲区
app.ecgData = zeros(12, 10000);
app.filteredData = zeros(12, 10000);
app.timeStamps = zeros(1, 10000);
app.currentIndex = 1;
app.dataLength = 0;
% 启用数据导航
set(app.dataSlider, 'Enable', 'on');
set(app.statusText, 'String', ['已连接到串口: ' app.comPort ' (波特率: ' num2str(app.baudRate) ')']);
app.isConnected = true;
catch ME
errordlg(['串口连接失败: ' ME.message], '连接错误');
app.isConnected = false;
end
end
function disconnect(app, ~, ~)
if ~isempty(app.serialPort) && isvalid(app.serialPort)
fclose(app.serialPort);
delete(app.serialPort);
app.serialPort = [];
set(app.statusText, 'String', '已断开串口连接');
app.isRecording = false;
app.isConnected = false;
end
end
function frameProcessor(app, src)
if src.NumBytesAvailable > 0
% OEM协议帧处理器实现
byte = read(src, 1, 'uint8');
% 帧起始检测 (0xAA)
if byte == 0xAA && ~app.inFrame
app.inFrame = true;
app.frameBuffer = uint8(byte);
return;
end
% 帧接收中
if app.inFrame
app.frameBuffer(end+1) = byte;
% 帧长度检测 (第2字节为DLC)
if length(app.frameBuffer) == 2
app.frameLength = byte + 3; % DLC + CRC + EOF
end
% 帧结束检测 (0x55)
if length(app.frameBuffer) >= app.frameLength
if byte == 0x55
% 处理有效数据帧(去除帧头、CRC和帧尾)
dataFrame = app.frameBuffer(3:end-2);
if app.checkCRC(dataFrame)
app.processECGFrame(dataFrame);
else
warning('接收到CRC校验失败的帧');
end
end
app.inFrame = false;
app.frameBuffer = [];
end
end
end
end
function processECGFrame(app, dataFrame)
% 解析OEM协议ECG数据帧
% 假设帧格式: [ID][DLC][12导联数据(2字节/导联)][CRC]
if length(dataFrame) >= 25 % 12导联*2字节 + 其他信息
% 提取12导联数据(假设每导联2字节)
ecgValues = zeros(12, 1);
for i = 1:12
byte1 = dataFrame(2+i*2-2);
byte2 = dataFrame(2+i*2-1);
ecgValues(i) = typecast(uint16(bitshift(uint8(byte2),8) + uint8(byte1)), 'int16');
ecgValues(i) = ecgValues(i) * 0.01; % 转换为mV单位
end
% 提取时间戳(假设前4字节为时间戳)
timestamp = typecast(uint32(bitshift(uint8(dataFrame(3)),24) + ...
bitshift(uint8(dataFrame(4)),16) + ...
bitshift(uint8(dataFrame(5)),8) + ...
uint8(dataFrame(6))), 'uint32');
% 存储数据
if app.dataLength < size(app.ecgData, 2)
app.dataLength = app.dataLength + 1;
end
app.timeStamps(app.dataLength) = timestamp / 1000; % 转换为秒
app.ecgData(:, app.dataLength) = ecgValues;
% 实时滤波
app.realTimeFiltering(app.dataLength);
% 更新显示
app.updatePlots();
% 实时分析
if mod(app.dataLength, 50) == 0
app.realTimeAnalysis();
end
% 更新数据导航
set(app.dataSlider, 'Value', app.dataLength / size(app.ecgData, 2));
set(app.positionText, 'String',...
sprintf('%d/%d', app.dataLength, size(app.ecgData, 2)));
end
end
function valid = checkCRC(app, data)
% CRC-16校验实现
crc = uint16(0xFFFF);
for i = 1:length(data)
crc = bitxor(crc, bitshift(uint16(data(i)), 8));
for j = 1:8
if bitand(crc, 0x8000)
crc = bitxor(bitshift(crc, 1), 0x1021);
else
crc = bitshift(crc, 1);
end
end
end
valid = (crc == 0);
end
function startSession(app, sessionType)
% UDS会话管理服务实现(0x10服务)
if sessionType == 1 && app.securityLevel >= 2
app.currentSession = sessionType;
% 发送肯定响应(示例)
response = [0x50, sessionType];
app.sendUDSResponse(response);
set(findobj(app.controlPanel, 'String', '会话:'), 'String', '会话: 扩展诊断');
else
% 发送否定响应(安全访问拒绝)
response = [0x7F, 0x10, 0x33];
app.sendUDSResponse(response);
warndlg('安全访问级别不足,无法启动扩展诊断会话', '安全错误');
end
end
function sendUDSResponse(app, data)
% 发送UDS协议响应帧
if isvalid(app.serialPort)
% 构建完整帧: 帧头(0xAA) + DLC + 数据 + CRC + 帧尾(0x55)
dlc = uint8(length(data));
frame = [uint8(0xAA), dlc, data];
% 计算CRC
crc = app.calculateCRC(frame);
frame = [frame, uint8(bitand(crc, 0xFF)), uint8(bitshift(crc, -8)), uint8(0x55)];
% 发送帧
write(app.serialPort, frame, 'uint8');
end
end
function crc = calculateCRC(app, data)
% 计算CRC-16校验值
crc = uint16(0xFFFF);
for i = 1:length(data)
crc = bitxor(crc, bitshift(uint16(data(i)), 8));
for j = 1:8
if bitand(crc, 0x8000)
crc = bitxor(bitshift(crc, 1), 0x1021);
else
crc = bitshift(crc, 1);
end
end
end
end
%% 数据处理模块
function realTimeFiltering(app, idx)
% 实时滤波处理
if idx == 1
% 初始化滤波数据
app.filteredData(:, idx) = app.ecgData(:, idx);
return;
end
% 获取滤波参数
hpFreq = str2double(get(app.highPassEdit, 'String'));
lpFreq = str2double(get(app.lowPassEdit, 'String'));
useNotch = get(app.notchCheckbox, 'Value');
for i = 1:12
% 应用高通滤波
if ~isnan(hpFreq) && hpFreq > 0
[b, a] = butter(2, hpFreq/(app.sampleRate/2), 'high');
if idx > 10
segment = app.ecgData(i, max(1, idx-10):idx);
filteredSeg = filtfilt(b, a, segment);
app.filteredData(i, idx) = filteredSeg(end);
else
app.filteredData(i, idx) = app.ecgData(i, idx);
end
else
app.filteredData(i, idx) = app.ecgData(i, idx);
end
% 应用低通滤波
if ~isnan(lpFreq) && lpFreq > 0
[b, a] = butter(4, lpFreq/(app.sampleRate/2), 'low');
if idx > 10
segment = app.filteredData(i, max(1, idx-10):idx);
filteredSeg = filtfilt(b, a, segment);
app.filteredData(i, idx) = filteredSeg(end);
end
end
% 应用50Hz陷波
if useNotch
wo = 50/(app.sampleRate/2);
bw = wo/10;
[b, a] = iirnotch(wo, bw);
if idx > 10
segment = app.filteredData(i, max(1, idx-10):idx);
filteredSeg = filtfilt(b, a, segment);
app.filteredData(i, idx) = filteredSeg(end);
end
end
end
end
function applyFilters(app, ~, ~)
% 应用全局滤波
if app.dataLength > 0
% 获取滤波参数
hpFreq = str2double(get(app.highPassEdit, 'String'));
lpFreq = str2double(get(app.lowPassEdit, 'String'));
useNotch = get(app.notchCheckbox, 'Value');
for i = 1:12
% 高通滤波
if ~isnan(hpFreq) && hpFreq > 0
[b, a] = butter(2, hpFreq/(app.sampleRate/2), 'high');
app.filteredData(i, 1:app.dataLength) = ...
filtfilt(b, a, app.ecgData(i, 1:app.dataLength));
end
% 低通滤波
if ~isnan(lpFreq) && lpFreq > 0
[b, a] = butter(4, lpFreq/(app.sampleRate/2), 'low');
app.filteredData(i, 1:app.dataLength) = ...
filtfilt(b, a, app.filteredData(i, 1:app.dataLength));
end
% 50Hz陷波
if useNotch
wo = 50/(app.sampleRate/2);
bw = wo/10;
[b, a] = iirnotch(wo, bw);
app.filteredData(i, 1:app.dataLength) = ...
filtfilt(b, a, app.filteredData(i, 1:app.dataLength));
end
end
% 更新显示
app.updatePlots();
app.updateSpectrum();
set(app.statusText, 'String', '全局滤波已应用');
end
end
%% 实时分析模块
function realTimeAnalysis(app)
% 实时分析ECG信号
if app.dataLength > 100
% 在II导联检测R波
leadIndex = 2;
data = app.filteredData(leadIndex, max(1, app.dataLength-500):app.dataLength);
% 使用Pan-Tompkins算法检测R波
[~, qrs_i_raw] = app.pan_tompkins(data, app.sampleRate);
% 计算心率
if length(qrs_i_raw) > 1
rrIntervals = diff(qrs_i_raw) / app.sampleRate;
app.heartRate = 60 / mean(rrIntervals);
set(app.heartRateText, 'String', sprintf('%.1f bpm', app.heartRate));
% 更新图形标记
if ishandle(app.leadPlots(leadIndex))
ax = get(app.leadPlots(leadIndex), 'Parent');
hold(ax, 'on');
if ~isempty(findobj(ax, 'Tag', 'RPeakMarker'))
delete(findobj(ax, 'Tag', 'RPeakMarker'));
end
% 计算实际时间位置
timeOffset = app.timeStamps(max(1, app.dataLength-500));
timePos = app.timeStamps(qrs_i_raw + max(1, app.dataLength-500) - 1);
plot(ax, timePos, data(qrs_i_raw), 'ro', 'MarkerSize', 6, 'MarkerFaceColor', 'r', 'Tag', 'RPeakMarker');
hold(ax, 'off');
end
end
end
end
function [qrs_amp_raw, qrs_i_raw] = pan_tompkins(~, ecg, fs)
% Pan-Tompkins R波检测算法
% 1. 带通滤波 (5-15 Hz)
f1 = 5/(fs/2);
f2 = 15/(fs/2);
[b, a] = butter(1, [f1, f2], 'bandpass');
ecg_filtered = filtfilt(b, a, ecg);
% 2. 微分
diff_ecg = diff(ecg_filtered);
diff_ecg = [diff_ecg(1), diff_ecg];
% 3. 平方
sqr_ecg = diff_ecg .^ 2;
% 4. 移动平均窗口积分
window_size = round(0.150 * fs);
integrated_ecg = movmean(sqr_ecg, window_size);
% 5. 自适应阈值检测
max_h = max(integrated_ecg);
threshold = 0.2 * max_h;
% 6. 寻找R波位置
[qrs_amp_raw, qrs_i_raw] = findpeaks(integrated_ecg,...
'MinPeakHeight', threshold,...
'MinPeakDistance', round(0.2*fs));
end
%% 数据管理
function toggleRecording(app, src, ~)
% 开始/停止记录数据
if src.Value
app.isRecording = true;
set(src, 'String', '停止记录');
set(app.statusText, 'String', '记录中...');
% 初始化记录缓冲区
app.ecgData = zeros(12, 10000);
app.filteredData = zeros(12, 10000);
app.timeStamps = zeros(1, 10000);
app.currentIndex = 1;
app.dataLength = 0;
else
app.isRecording = false;
set(src, 'String', '开始记录');
set(app.statusText, 'String', '记录已停止');
end
end
function loadDataFile(app, ~, ~)
% 加载ECG数据文件
[filename, pathname] = uigetfile({'*.mat;*.csv;*.txt', '数据文件 (*.mat, *.csv, *.txt)'},...
'选择ECG数据文件');
if isequal(filename, 0)
return;
end
filepath = fullfile(pathname, filename);
[~, ~, ext] = fileparts(filepath);
try
h = waitbar(0, '加载数据中...', 'Name', '数据处理');
switch lower(ext)
case '.mat'
data = load(filepath);
waitbar(0.3, h);
if isfield(data, 'ecgData')
app.ecgData = data.ecgData;
app.filteredData = data.filteredData;
app.timeStamps = data.timeStamps;
app.sampleRate = data.sampleRate;
else
error('无法识别MAT文件中的数据格式');
end
case {'.csv', '.txt'}
data = readmatrix(filepath);
waitbar(0.3, h);
if size(data, 2) >= 13
app.timeStamps = data(:, 1)';
app.ecgData = data(:, 2:13)';
app.sampleRate = 1 / mean(diff(app.timeStamps));
else
error('CSV/TXT文件应包含时间戳和12导联数据');
end
end
app.dataLength = size(app.ecgData, 2);
app.currentIndex = app.dataLength;
% 应用滤波
waitbar(0.7, h, '应用滤波...');
app.applyFilters();
% 更新界面
waitbar(0.9, h, '更新界面...');
app.updatePlots();
app.updateSpectrum();
% 配置导航滑块
set(app.dataSlider, 'Min', 0, 'Max', 1, 'Value', 1,...
'SliderStep', [100/app.dataLength, 1000/app.dataLength],...
'Enable', 'on');
set(app.positionText, 'String',...
sprintf('%d/%d', app.dataLength, size(app.ecgData, 2)));
set(app.statusText, 'String',...
sprintf('已加载: %s (%d个样本)', filename, app.dataLength));
waitbar(1, h, '完成!');
pause(0.5);
delete(h);
catch ME
errordlg(sprintf('加载失败: %s', ME.message), '文件错误');
end
end
function saveData(app, ~, ~)
% 保存ECG数据
if app.dataLength == 0
errordlg('没有可保存的数据', '保存错误');
return;
end
[filename, pathname] = uiputfile({'*.mat', 'MAT文件 (*.mat)'; '*.csv', 'CSV文件 (*.csv)'},...
'保存ECG数据', 'ecg_data.mat');
if isequal(filename, 0)
return;
end
filepath = fullfile(pathname, filename);
[~, ~, ext] = fileparts(filepath);
try
app.ecgData = app.ecgData(:, 1:app.dataLength);
app.filteredData = app.filteredData(:, 1:app.dataLength);
app.timeStamps = app.timeStamps(1:app.dataLength);
app.sampleRate = app.sampleRate;
switch lower(ext)
case '.mat'
save(filepath, 'ecgData', 'filteredData', 'timeStamps', 'sampleRate');
case '.csv'
dataToSave = [app.timeStamps', app.ecgData'];
writematrix(dataToSave, filepath);
end
set(app.statusText, 'String', sprintf('数据已保存至: %s', filename));
catch ME
errordlg(sprintf('保存失败: %s', ME.message), '保存错误');
end
end
%% 显示更新
function updatePlots(app)
% 更新所有窗口的心电图显示
if app.dataLength > 0
windowSize = 500; % 显示窗口大小
startIdx = max(1, app.currentIndex - windowSize);
endIdx = min(app.dataLength, app.currentIndex + windowSize);
timeWindow = app.timeStamps(startIdx:endIdx);
% 更新总览窗口
if ishandle(app.overviewFig)
for i = 1:12
if ishandle(app.overviewPlots(i))
set(app.overviewPlots(i), 'XData', timeWindow,...
'YData', app.filteredData(i, startIdx:endIdx));
% 自动调整Y轴范围
dataRange = range(app.filteredData(i, startIdx:endIdx));
if dataRange > 0
ax = get(app.overviewPlots(i), 'Parent');
ylim(ax, [min(app.filteredData(i, startIdx:endIdx)) - 0.1*dataRange,...
max(app.filteredData(i, startIdx:endIdx)) + 0.1*dataRange]);
end
end
end
drawnow;
end
% 更新单导联窗口
for i = 1:12
if ishandle(app.singleLeadFigs(i)) && ishandle(app.leadPlots(i))
set(app.leadPlots(i), 'XData', timeWindow,...
'YData', app.filteredData(i, startIdx:endIdx));
% 自动调整Y轴范围
dataRange = range(app.filteredData(i, startIdx:endIdx));
if dataRange > 0
ax = get(app.leadPlots(i), 'Parent');
ylim(ax, [min(app.filteredData(i, startIdx:endIdx)) - 0.1*dataRange,...
max(app.filteredData(i, startIdx:endIdx)) + 0.1*dataRange]);
end
drawnow;
end
end
end
end
function updateSpectrumForLead(app, leadIndex)
% 更新指定导联的频谱分析
if app.dataLength > 0 && leadIndex >= 1 && leadIndex <= 12
signal = app.filteredData(leadIndex, max(1, app.dataLength-2000):app.dataLength);
% 计算FFT
N = length(signal);
if N < 10, return; end
f = (0:N-1)*(app.sampleRate/N);
Y = fft(signal);
P2 = abs(Y/N);
P1 = P2(1:floor(N/2)+1);
P1(2:end-1) = 2*P1(2:end-1);
f = f(1:floor(N/2)+1);
% 更新频谱分析图
plot(app.spectrumAx, f, 20*log10(P1), 'b');
title(app.spectrumAx, sprintf('导联 %s 频谱', app.leadNames{leadIndex}));
xlabel(app.spectrumAx, '频率 (Hz)');
ylabel(app.spectrumAx, '幅度 (dB)');
xlim(app.spectrumAx, [0, min(150, app.sampleRate/2)]);
grid(app.spectrumAx, 'on');
end
end
function showSpectrumForLead(app, leadIndex)
% 在单独窗口中显示导联频谱
if app.dataLength > 0 && leadIndex >= 1 && leadIndex <= 12
figName = sprintf('导联 %s 频谱分析', app.leadNames{leadIndex});
app.fig = figure('Name', figName, 'NumberTitle', 'off',...
'Position', [200, 200, 600, 400]);
ax = axes('Parent', app.fig, 'Position', [0.1, 0.15, 0.85, 0.75]);
signal = app.filteredData(leadIndex, max(1, app.dataLength-2000):app.dataLength);
N = length(signal);
f = (0:N-1)*(app.sampleRate/N);
Y = fft(signal);
P2 = abs(Y/N);
P1 = P2(1:floor(N/2)+1);
P1(2:end-1) = 2*P1(2:end-1);
f = f(1:floor(N/2)+1);
plot(ax, f, 20*log10(P1), 'b');
title(ax, sprintf('导联 %s 频谱分析', app.leadNames{leadIndex}));
xlabel(ax, '频率 (Hz)');
ylabel(ax, '幅度 (dB)');
xlim(ax, [0, min(150, app.sampleRate/2)]);
grid(ax, 'on');
end
end
function detectRPeaksForLead(app, leadIndex)
% 检测指定导联的R波
if app.dataLength > 0 && leadIndex >= 1 && leadIndex <= 12
data = app.filteredData(leadIndex, max(1, app.dataLength-2000):app.dataLength);
% 使用Pan-Tompkins算法检测R波
[~, qrs_i_raw] = app.pan_tompkins(data, app.sampleRate);
% 在图形上标记R波
if ishandle(app.singleLeadFigs(leadIndex))
app.fig = app.singleLeadFigs(leadIndex);
ax = findobj(app.fig, 'Type', 'axes');
% 清除旧标记
oldMarkers = findobj(ax, 'Tag', 'RPeakMarker');
if ~isempty(oldMarkers)
delete(oldMarkers);
end
% 添加新标记
hold(ax, 'on');
plot(ax, app.timeStamps(qrs_i_raw + max(1, app.dataLength-2000) - 1),...
data(qrs_i_raw), 'ro', 'MarkerSize', 8, 'MarkerFaceColor', 'r',...
'Tag', 'RPeakMarker');
hold(ax, 'off');
% 计算并显示心率
if length(qrs_i_raw) > 1
rrIntervals = diff(qrs_i_raw) / app.sampleRate;
app.heartRate = 60 / mean(rrIntervals);
title(ax, sprintf('导联 %s - 心率: %.1f bpm',...
app.leadNames{leadIndex}, app.heartRate));
end
end
end
end
%% 报告生成
function setPatientInfo(app, ~, ~)
% 设置患者信息
prompt = {'姓名:', '性别:', '年龄:', '患者ID:'};
dlgtitle = '患者信息';
dims = [1 25];
definput = {app.patientInfo.name, app.patientInfo.gender,...
num2str(app.patientInfo.age), app.patientInfo.id};
answer = inputdlg(prompt, dlgtitle, dims, definput);
if ~isempty(answer)
app.patientInfo.name = answer{1};
app.patientInfo.gender = answer{2};
app.patientInfo.age = str2double(answer{3});
app.patientInfo.id = answer{4};
app.patientInfo.date = datestr(now, 'yyyy-mm-dd HH:MM');
end
end
function generateReport(app, ~, ~)
% 生成心电图报告
if app.dataLength == 0
errordlg('没有数据可生成报告', '报告错误');
return;
end
[filename, pathname] = uiputfile('*.pdf', '保存心电图报告', 'ECG_Report.pdf');
if filename == 0
return;
end
pdfFile = fullfile(pathname, filename);
app.exportToPDF(pdfFile);
msgbox(sprintf('心电图报告已保存至:\n%s', pdfFile), '报告生成');
end
function exportToPDF(app, filename)
% 导出PDF报告
app.fig = figure('Visible', 'off', 'Position', [100, 100, 1200, 900]);
% 报告标题
uicontrol('Style', 'text', 'String', '心电图报告',...
'Position', [400, 850, 400, 40], 'FontSize', 20, 'FontWeight', 'bold');
% 患者信息
app.patientInfo = {sprintf('姓名: %s', app.patientInfo.name),...
sprintf('性别: %s', app.patientInfo.gender),...
sprintf('年龄: %d', app.patientInfo.age),...
sprintf('ID: %s', app.patientInfo.id),...
sprintf('检查日期: %s', app.patientInfo.date)};
uicontrol('Style', 'text', 'String', app.patientInfo,...
'Position', [50, 780, 300, 100], 'FontSize', 12, 'HorizontalAlignment', 'left');
% 分析结果
analysisInfo = {sprintf('心率: %.1f bpm', app.heartRate),...
sprintf('采样率: %d Hz', app.sampleRate),...
sprintf('数据长度: %.1f 秒', app.timeStamps(app.dataLength))};
uicontrol('Style', 'text', 'String', analysisInfo,...
'Position', [50, 680, 300, 60], 'FontSize', 12, 'HorizontalAlignment', 'left');
% 绘制12导联心电图
for i = 1:12
row = ceil(i/3);
col = mod(i-1,3)+1;
ax = subplot(4,3,i, 'Parent', app.fig);
if app.dataLength > 0
% 显示完整数据
plot(ax, app.timeStamps(1:app.dataLength),...
app.filteredData(i, 1:app.dataLength), 'b-');
end
title(ax, app.leadNames{i});
xlabel(ax, '时间(s)');
ylabel(ax, '幅度(mV)');
grid(ax, 'on');
end
% 导出为PDF
exportgraphics(app.fig, filename, 'ContentType', 'vector');
close(app.fig);
end
%% 系统控制
function onClose(app, ~, ~)
% 关闭应用程序
app.disconnect();
% 关闭所有窗口
if ishandle(app.overviewFig)
delete(app.overviewFig);
end
for i = 1:12
if ishandle(app.singleLeadFigs(i))
delete(app.singleLeadFigs(i));
end
end
delete(app.fig);
end
end
end
% 辅助函数
function result = iff(condition, trueValue, falseValue)
if condition
result = trueValue;
else
result = falseValue;
end
end