在心电信号处理中,R峰检测是心率变异性分析、波形分类等任务的基础。一个可靠的检测系统不仅需要较高的自动化精度,还应提供灵活的交互校正手段,以应对信号质量差、异位搏动等复杂情况。本文分享一个基于MATLAB的工具,它整合了鲁棒的R峰自动检测、高斯目标波形生成以及功能丰富的交互式检查与编辑界面。
本文适配60s信号,采样率128hz,具体参数可自行更改。
Matlab
% 输入:E:\Project2data\ecgdata\E*.csv(单列 128 Hz ECG)
% 输出:E:\Project2data\ecgdataMarked\E*.csv(三列:ECG_norm, Gauss_norm, R_peak标记)
clear; clc;
%% 参数设置
inputDir = 'E:\Project2data\ecgdata';
outputDir = 'E:\Project2data\ecgdataMarked';
fs = 128;
snapWin = 3; % 吸附峰值的窗口半宽(点数),基础处理时使用
if ~exist(outputDir, 'dir')
mkdir(outputDir);
end
%% 获取所有输入文件
files = dir(fullfile(inputDir, 'E*.csv'));
if isempty(files)
error('未找到输入文件,请检查路径:%s', inputDir);
end
[~, idx] = sort({files.name});
files = files(idx);
total = length(files);
fprintf('共找到 %d 个文件\n', total);
%% 批量处理:生成带标记的三列数据(含吸附峰值优化)
for i = 1:total
inFile = fullfile(inputDir, files(i).name);
ecg_raw = readmatrix(inFile);
ecg_raw = ecg_raw(:);
% 1. R 峰检测(改进版:精确定位到正向最高点)
r_peaks = detect_r_peaks_robust(ecg_raw, fs);
% 2. 吸附峰值:将每个R峰对齐到局部窗口内的真正最大值(正向)
r_peaks = refine_peaks_to_local_max(ecg_raw, r_peaks, snapWin);
% 3. 生成高斯波形(卷积实现,高效率)
gauss = generate_gaussian_wave(ecg_raw, r_peaks, fs);
% 4. 标准化
ecg_norm = (ecg_raw - mean(ecg_raw)) / std(ecg_raw);
gauss_norm = (gauss - mean(gauss)) / std(gauss);
% 5. R 峰标记
mark = zeros(length(ecg_raw), 1);
mark(r_peaks) = 1;
% 6. 保存三列数据
outData = [ecg_norm, gauss_norm, mark];
outFile = fullfile(outputDir, files(i).name);
writematrix(outData, outFile);
if mod(i, 50) == 0
fprintf('已处理 %d / %d 个文件\n', i, total);
end
end
fprintf('数据生成完成!输出路径:%s\n', outputDir);
%% 询问是否启动交互检查
resp = input('是否启动交互检查界面?(y/n, 默认 y): ', 's');
if isempty(resp) || lower(resp) ~= 'n'
interactive_check(outputDir, fs);
end
%% ==================== 以下为所有函数定义 ====================
% ----------------------------------------------------------------------
% 交互检查与编辑工具(支持基准峰选择、基于基准重测等)
% ----------------------------------------------------------------------
function interactive_check(dataDir, fs)
outFiles = dir(fullfile(dataDir, 'E*.csv'));
if isempty(outFiles)
warning('输出目录中没有文件');
return;
end
[~, idx] = sort({outFiles.name});
outFiles = outFiles(idx);
total = length(outFiles);
firstData = readmatrix(fullfile(dataDir, outFiles(1).name));
N = size(firstData, 1);
t = (0:N-1) / fs;
fig = figure('Name', 'R峰检测检查与编辑工具', ...
'NumberTitle', 'off', 'MenuBar', 'none', 'ToolBar', 'figure', ...
'Position', [100, 100, 1400, 700], 'Resize', 'on');
panelHeight = 130;
btnPanel = uipanel('Parent', fig, 'Title', '控制', ...
'Units', 'pixels', 'Position', [10, 5, 1380, panelHeight]);
leftX = 10; yBtn = 8; btnW = 100; btnH = 28;
uicontrol('Parent', btnPanel, 'Style', 'pushbutton', 'String', '← 上一个', ...
'Position', [leftX, yBtn, btnW, btnH], 'Callback', @(~,~) switchFile(-1));
uicontrol('Parent', btnPanel, 'Style', 'pushbutton', 'String', '下一个 →', ...
'Position', [leftX+110, yBtn, btnW, btnH], 'Callback', @(~,~) switchFile(1));
uicontrol('Parent', btnPanel, 'Style', 'pushbutton', 'String', '保存 (Ctrl+S)', ...
'Position', [leftX+220, yBtn, btnW+20, btnH], 'Callback', @(~,~) saveCurrentFile());
statusText = uicontrol('Parent', btnPanel, 'Style', 'text', 'String', '就绪', ...
'Position', [leftX+360, yBtn, 350, btnH], 'HorizontalAlignment', 'left', ...
'FontSize', 10, 'ForegroundColor', [0 0.6 0]);
midX = 750;
row1Y = yBtn;
row2Y = yBtn + btnH + 5;
baseBtn = uicontrol('Parent', btnPanel, 'Style', 'togglebutton', 'String', '选择基准峰', ...
'Position', [midX, row1Y, 100, btnH], 'Callback', @(~,~) toggleBaseMode());
uicontrol('Parent', btnPanel, 'Style', 'pushbutton', 'String', '清除基准峰', ...
'Position', [midX+110, row1Y, 100, btnH], 'Callback', @(~,~) clearBasePeaks());
baseCountText = uicontrol('Parent', btnPanel, 'Style', 'text', 'String', '基准峰:0', ...
'Position', [midX+220, row1Y, 100, btnH], 'HorizontalAlignment', 'left', ...
'FontSize', 10, 'FontWeight', 'bold');
uicontrol('Parent', btnPanel, 'Style', 'pushbutton', 'String', '基于基准重测', ...
'Position', [midX, row2Y, 110, btnH], 'Callback', @(~,~) redetectWithBase());
rightX = 1050;
uicontrol('Parent', btnPanel, 'Style', 'pushbutton', 'String', '吸附峰值', ...
'Position', [rightX, row1Y, 80, btnH], 'Callback', @(~,~) snapToPeaks());
uicontrol('Parent', btnPanel, 'Style', 'text', 'String', '窗口±:', ...
'Position', [rightX+90, row1Y+5, 40, 20], 'HorizontalAlignment', 'right');
snapWinEdit = uicontrol('Parent', btnPanel, 'Style', 'edit', 'String', '3', ...
'Position', [rightX+130, row1Y, 50, btnH]);
uicontrol('Parent', btnPanel, 'Style', 'pushbutton', 'String', '整体左移1', ...
'Position', [rightX, row2Y, 80, btnH], 'Callback', @(~,~) shiftPeaks(-1));
uicontrol('Parent', btnPanel, 'Style', 'pushbutton', 'String', '整体右移1', ...
'Position', [rightX+90, row2Y, 80, btnH], 'Callback', @(~,~) shiftPeaks(1));
uicontrol('Parent', btnPanel, 'Style', 'pushbutton', 'String', '删除密峰', ...
'Position', [rightX+180, row2Y, 80, btnH], 'Callback', @(~,~) removeClosePeaks());
uicontrol('Parent', btnPanel, 'Style', 'pushbutton', 'String', '补全首尾', ...
'Position', [rightX+270, row2Y, 80, btnH], 'Callback', @(~,~) fillEnds());
ax1 = axes('Parent', fig, 'Units', 'pixels');
ax2 = axes('Parent', fig, 'Units', 'pixels');
hold(ax1, 'on'); hold(ax2, 'on');
set(ax1, 'ButtonDownFcn', @onAxesClick);
cache = containers.Map('KeyType', 'int32', 'ValueType', 'any');
currentIdx = 1;
modified = false;
currentData = [];
currentMark = [];
currentRPeaks = [];
basePeaks = []; % 存储基准峰索引
baseHandle = []; % 基准峰图形句柄
hEcg = []; hMark = [];
baseMode = false; % 是否处于基准峰编辑模式
function updateStatus(msg, isModified)
if nargin > 1
modified = isModified;
end
if modified
set(statusText, 'String', sprintf('⚠️ 未保存 | %s', msg), 'ForegroundColor', [0.8 0 0]);
else
set(statusText, 'String', sprintf('✓ 已保存 | %s', msg), 'ForegroundColor', [0 0.6 0]);
end
drawnow;
end
function updateBaseCountDisplay()
set(baseCountText, 'String', sprintf('基准峰:%d', length(basePeaks)));
end
function toggleBaseMode()
baseMode = ~baseMode;
if baseMode
set(baseBtn, 'BackgroundColor', [0.8 0.8 0.8], 'String', '退出基准编辑');
updateStatus('基准峰编辑模式:点击绿色R峰添加红色基准峰,点击红色基准峰删除', true);
else
set(baseBtn, 'BackgroundColor', [0.94 0.94 0.94], 'String', '选择基准峰');
updateStatus('普通模式:点击可增删绿色R峰', true);
end
end
function refreshDisplay()
cla(ax1);
hEcg = plot(ax1, t, currentData(:,1), 'b-', 'LineWidth', 1);
set(hEcg, 'HitTest', 'off');
if ~isempty(currentRPeaks)
hMark = scatter(ax1, t(currentRPeaks), currentData(currentRPeaks,1), ...
80, 'go', 'MarkerEdgeColor', 'g', 'MarkerFaceColor', 'g', 'LineWidth', 2);
set(hMark, 'HitTest', 'off');
else
hMark = [];
end
% 基准峰用实心红点
if ~isempty(basePeaks)
baseHandle = scatter(ax1, t(basePeaks), currentData(basePeaks,1), ...
100, 'ro', 'MarkerEdgeColor', 'r', 'MarkerFaceColor', 'r', 'LineWidth', 1.5);
set(baseHandle, 'HitTest', 'off');
else
baseHandle = [];
end
title(ax1, sprintf('ECG信号 --- R峰数量: %d', length(currentRPeaks)));
ylabel(ax1, '幅度'); grid(ax1, 'on');
set(ax1, 'ButtonDownFcn', @onAxesClick);
end
function updateGaussianFromPeaks()
new_gauss = generate_gaussian_wave(currentData(:,1), currentRPeaks, fs);
currentData(:,2) = new_gauss;
cla(ax2);
plot(ax2, t, currentData(:,2), 'r-', 'LineWidth', 1);
title(ax2, '高斯目标波形(已同步)');
xlabel(ax2, '时间 (秒)'); ylabel(ax2, '幅度'); grid(ax2, 'on');
drawnow;
end
function saveCurrentFile()
if ~modified
updateStatus('无修改', false);
return;
end
currentData(:,3) = currentMark;
filePath = fullfile(dataDir, outFiles(currentIdx).name);
writematrix(currentData, filePath);
cache(currentIdx) = currentData;
updateStatus('保存成功', false);
fprintf('已保存: %s\n', outFiles(currentIdx).name);
end
function switchFile(delta)
newIdx = currentIdx + delta;
if newIdx < 1 || newIdx > total, return; end
if modified
choice = questdlg('当前文件未保存,是否保存?', '未保存更改', ...
'保存', '不保存', '取消', '保存');
switch choice
case '保存', saveCurrentFile();
case '取消', return;
end
end
currentIdx = newIdx;
loadAndDisplay(currentIdx);
updateStatus('已加载', false);
end
function loadAndDisplay(idx)
if cache.isKey(idx)
currentData = cache(idx);
else
currentData = readmatrix(fullfile(dataDir, outFiles(idx).name));
if cache.Count > 10
keys = cache.keys;
for k = 1:length(keys)-10, cache.remove(keys{k}); end
end
cache(idx) = currentData;
end
N = size(currentData, 1);
t = (0:N-1) / fs;
currentMark = currentData(:,3);
currentRPeaks = find(currentMark == 1);
basePeaks = []; % 切换文件时清空基准峰
refreshDisplay();
updateBaseCountDisplay();
cla(ax2);
plot(ax2, t, currentData(:,2), 'r-', 'LineWidth', 1);
title(ax2, '高斯目标波形(仅供参考)');
xlabel(ax2, '时间 (秒)'); ylabel(ax2, '幅度'); grid(ax2, 'on');
set(fig, 'Name', sprintf('%s (%d/%d) 点击"选择基准峰"标记典型R波', ...
outFiles(idx).name, idx, total));
end
% ------------------ 基准峰操作 ------------------
function clearBasePeaks()
basePeaks = [];
updateBaseCountDisplay();
refreshDisplay();
updateStatus('已清除所有基准峰', true);
end
function redetectWithBase()
if length(basePeaks) < 3
warndlg('请先选择至少3个基准峰(点击"选择基准峰"按钮后,在绿色R峰上点击添加红色标记)', '基准峰不足');
return;
end
baseVals = currentData(basePeaks, 1);
medianVal = median(baseVals);
if medianVal <= 0
warndlg('基准峰幅值异常(非正值),请重新选择', '错误');
return;
end
ecg_signal = currentData(:,1);
new_peaks = detect_r_peaks_with_reference(ecg_signal, fs, medianVal);
if isempty(new_peaks)
warndlg('基于基准峰检测失败,请尝试其他基准峰', '检测失败');
return;
end
currentRPeaks = new_peaks(:);
currentMark = zeros(N,1);
currentMark(currentRPeaks) = 1;
refreshDisplay();
updateGaussianFromPeaks();
updateStatus(sprintf('基于基准峰重测完成,共%d个峰', length(currentRPeaks)), true);
end
% ------------------ 鼠标点击(根据模式执行不同操作) ------------------
function onAxesClick(~, ~)
cp = get(ax1, 'CurrentPoint');
if isempty(cp), return; end
xClick = cp(1,1);
[~, idxClick] = min(abs(t - xClick));
if baseMode
% 基准峰编辑模式:添加或删除基准峰
% 先在已有基准峰中查找是否有距离<=5个点的
[minDist, nearestBaseIdx] = min(abs(basePeaks - idxClick));
if ~isempty(basePeaks) && minDist <= 5
% 删除该基准峰
basePeaks(nearestBaseIdx) = [];
updateBaseCountDisplay();
refreshDisplay();
updateStatus(sprintf('删除基准峰 @ %.3f s', t(idxClick)), true);
return;
end
% 否则,尝试添加基准峰:必须点击在现有的R峰上(或附近10点内)
[minDistR, nearestRIdx] = min(abs(currentRPeaks - idxClick));
if ~isempty(currentRPeaks) && minDistR <= 10
newBase = currentRPeaks(nearestRIdx);
if ~ismember(newBase, basePeaks)
if length(basePeaks) >= 15
msgbox('基准峰已达15个上限', '提示', 'warn');
return;
end
basePeaks = sort([basePeaks; newBase]);
updateBaseCountDisplay();
refreshDisplay();
updateStatus(sprintf('添加基准峰 @ %.3f s', t(newBase)), true);
else
updateStatus('该点已是基准峰', true);
end
else
updateStatus('请点击绿色R峰附近添加基准峰', true);
end
else
% 普通模式:手动增删R峰(原有逻辑)
searchRange = max(1, idxClick-5) : min(N, idxClick+5);
existing = intersect(currentRPeaks, searchRange);
if isempty(existing)
if currentData(idxClick,1) > 0.1
currentRPeaks = sort([currentRPeaks; idxClick]);
currentMark(idxClick) = 1;
updateStatus(sprintf('添加R峰 @ %.3f s', t(idxClick)), true);
else
updateStatus('该点ECG为负值,拒绝添加', true);
return;
end
else
delIdx = existing(1);
currentRPeaks(currentRPeaks == delIdx) = [];
currentMark(delIdx) = 0;
updateStatus(sprintf('删除R峰 @ %.3f s', t(delIdx)), true);
end
refreshDisplay();
updateGaussianFromPeaks();
end
end
% ------------------ 其他批量操作函数(不变) ------------------
function snapToPeaks()
winHalf = round(str2double(get(snapWinEdit, 'String')));
if isnan(winHalf) || winHalf < 0, winHalf = 3; end
newPeaks = zeros(size(currentRPeaks));
changed = false;
for i = 1:length(currentRPeaks)
p = currentRPeaks(i);
lo = max(1, p - winHalf);
hi = min(N, p + winHalf);
[~, maxIdx] = max(currentData(lo:hi, 1));
newP = lo + maxIdx - 1;
if newP ~= p, changed = true; end
newPeaks(i) = newP;
end
if changed
currentRPeaks = unique(newPeaks);
currentMark = zeros(N,1);
currentMark(currentRPeaks) = 1;
refreshDisplay();
updateGaussianFromPeaks();
updateStatus(sprintf('吸附峰值完成 (窗口±%d)', winHalf), true);
else
updateStatus('所有峰已在峰值点,无需调整', true);
end
end
function shiftPeaks(delta)
if delta == 0, return; end
newPeaks = currentRPeaks + delta;
valid = newPeaks >= 1 & newPeaks <= N;
newPeaks = newPeaks(valid);
if isempty(newPeaks)
updateStatus('偏移后无有效峰', false);
return;
end
newPeaks = sort(unique(newPeaks));
currentRPeaks = newPeaks;
currentMark = zeros(N,1);
currentMark(currentRPeaks) = 1;
refreshDisplay();
updateGaussianFromPeaks();
updateStatus(sprintf('整体偏移 %+d 点', delta), true);
end
function removeClosePeaks()
if length(currentRPeaks) < 2, return; end
minRR = round(0.4 * fs);
keep = true(size(currentRPeaks));
for i = 2:length(currentRPeaks)
if currentRPeaks(i) - currentRPeaks(i-1) < minRR
keep(i) = false;
end
end
newPeaks = currentRPeaks(keep);
if length(newPeaks) < length(currentRPeaks)
currentRPeaks = newPeaks;
currentMark = zeros(N,1);
currentMark(currentRPeaks) = 1;
refreshDisplay();
updateGaussianFromPeaks();
updateStatus(sprintf('删除了 %d 个过密峰', length(currentRPeaks)-length(newPeaks)), true);
else
updateStatus('没有过密峰需要删除', false);
end
end
function fillEnds()
changed = false;
if isempty(currentRPeaks) || currentRPeaks(1) > fs
searchEnd = min(fs, N);
[maxVal, maxIdx] = max(currentData(1:searchEnd, 1));
if maxVal > 0.1
currentRPeaks = sort([currentRPeaks; maxIdx]);
changed = true;
end
end
if isempty(currentRPeaks) || (N - currentRPeaks(end)) > fs
searchStart = max(1, N - fs + 1);
[maxVal, maxIdx] = max(currentData(searchStart:end, 1));
if maxVal > 0.1
actualIdx = searchStart + maxIdx - 1;
currentRPeaks = sort([currentRPeaks; actualIdx]);
changed = true;
end
end
if changed
currentMark = zeros(N,1);
currentMark(currentRPeaks) = 1;
refreshDisplay();
updateGaussianFromPeaks();
updateStatus('补全首尾漏检峰', true);
else
updateStatus('首尾已有峰或无可补正峰', false);
end
end
% ------------------ 键盘与窗口回调 ------------------
function onKeyPress(~, event)
isCtrl = ismember('control', event.Modifier);
switch event.Key
case 'rightarrow', switchFile(1);
case 'leftarrow', switchFile(-1);
case 's'
if isCtrl, saveCurrentFile(); end
case 'q'
if modified
choice = questdlg('有未保存修改,是否保存后退出?', '退出', ...
'保存并退出', '不保存退出', '取消', '保存并退出');
switch choice
case '保存并退出', saveCurrentFile(); close(fig);
case '不保存退出', close(fig);
case '取消', return;
end
else
close(fig);
end
end
end
function resizeCallback(~, ~)
figPos = get(fig, 'Position');
figW = figPos(3);
figH = figPos(4);
panelW = figW - 20;
set(btnPanel, 'Position', [10, 5, panelW, panelHeight]);
axBottom = panelHeight + 20;
axTopMargin = 30;
totalAxHeight = figH - axBottom - axTopMargin;
axHeight = max(150, floor((totalAxHeight - 20) / 2));
axWidth = figW - 40;
axLeft = 20;
set(ax1, 'Position', [axLeft, axBottom + axHeight + 20, axWidth, axHeight]);
set(ax2, 'Position', [axLeft, axBottom, axWidth, axHeight]);
end
set(fig, 'WindowKeyPressFcn', @onKeyPress);
set(fig, 'ResizeFcn', @resizeCallback);
loadAndDisplay(1);
resizeCallback();
uiwait(fig);
end
% ----------------------------------------------------------------------
% 基于基准峰幅值的 R 峰检测(固定初始阈值,避免高幅早搏干扰)
% ----------------------------------------------------------------------
function r_peaks = detect_r_peaks_with_reference(ecg, fs, refAmp)
low_cut = 5; high_cut = 15;
Wn = [low_cut, high_cut] / (fs/2);
[b, a] = butter(2, Wn, 'bandpass');
filtered_ecg = filtfilt(b, a, ecg);
diff_ecg = diff(filtered_ecg);
diff_ecg = [diff_ecg; 0];
squared_ecg = diff_ecg .^ 2;
window_len = max(1, round(0.15 * fs));
integ_ecg = conv(squared_ecg, ones(window_len,1), 'same');
N = length(integ_ecg);
% 基于基准幅值设定初始阈值(经验比例0.4)
init_thresh = refAmp * 0.4;
threshold_peak = init_thresh;
r_peaks = [];
refrac = round(0.2 * fs);
last_r = -refrac;
peak_level = refAmp;
noise_level = refAmp * 0.2;
search_half = min(15, round(0.12 * fs));
for i = round(0.2*fs)+1 : N-1
val = integ_ecg(i);
if val > integ_ecg(i-1) && val >= integ_ecg(i+1)
if val > threshold_peak && (i - last_r) > refrac
win_start = max(1, i - search_half);
win_end = min(length(filtered_ecg), i + search_half);
win = win_start:win_end;
[max_val, max_idx_in_win] = max(filtered_ecg(win));
r_idx_candidate = win(max_idx_in_win);
if max_val > 0
r_peaks = [r_peaks; r_idx_candidate];
last_r = i;
if val > peak_level
peak_level = 0.9 * peak_level + 0.1 * val;
else
peak_level = 0.125 * val + 0.875 * peak_level;
end
end
else
noise_level = 0.125 * val + 0.875 * noise_level;
end
threshold_peak = noise_level + 0.25 * (peak_level - noise_level);
threshold_peak = max(threshold_peak, 0.1 * peak_level);
end
end
min_rr = round(0.4 * fs);
if length(r_peaks) > 1
keep = true(size(r_peaks));
for k = 2:length(r_peaks)
if r_peaks(k) - r_peaks(k-1) < min_rr
keep(k) = false;
end
end
r_peaks = r_peaks(keep);
end
if ~isempty(r_peaks) && N > 2*fs
mean_rr = median(diff(r_peaks));
if isnan(mean_rr), mean_rr = round(0.8*fs); end
if r_peaks(1) > 0.5*fs
search_range = 1:min(r_peaks(1)-1, round(mean_rr));
[max_val, pos] = max(filtered_ecg(search_range));
if max_val > 0.2*refAmp
r_peaks = [pos; r_peaks];
end
end
if N - r_peaks(end) > 0.5*fs
search_range = max(r_peaks(end)+1, N-round(mean_rr)) : N;
[max_val, pos] = max(filtered_ecg(search_range));
if max_val > 0.2*refAmp
r_peaks = [r_peaks; search_range(pos)];
end
end
r_peaks = unique(r_peaks);
end
if isempty(r_peaks)
[max_val, idx] = max(filtered_ecg);
if max_val > 0
r_peaks = idx;
end
end
end
% ----------------------------------------------------------------------
% 稳健的 R 峰检测(原始版本,用于批量处理)
% ----------------------------------------------------------------------
function r_peaks = detect_r_peaks_robust(ecg, fs)
default_thresholds = [0.5, 0.35, 0.25];
r_peaks = detect_r_peaks_robust_with_thresholds(ecg, fs, default_thresholds);
end
function r_peaks = detect_r_peaks_robust_with_thresholds(ecg, fs, threshold_factors)
low_cut = 5; high_cut = 15;
Wn = [low_cut, high_cut] / (fs/2);
[b, a] = butter(2, Wn, 'bandpass');
filtered_ecg = filtfilt(b, a, ecg);
diff_ecg = diff(filtered_ecg);
diff_ecg = [diff_ecg; 0];
squared_ecg = diff_ecg .^ 2;
window_len = max(1, round(0.15 * fs));
integ_ecg = conv(squared_ecg, ones(window_len,1), 'same');
N = length(integ_ecg);
best_peaks = [];
best_count = 0;
for tf = threshold_factors
init_len = min(2 * fs, N);
init_vals = integ_ecg(1:init_len);
init_median = median(init_vals);
init_max = max(init_vals);
init_thresh = init_median + tf * (init_max - init_median);
threshold_peak = init_thresh;
r_peaks = [];
refrac = round(0.2 * fs);
last_r = -refrac;
peak_level = init_max;
noise_level = init_median;
search_half = min(15, round(0.12 * fs));
for i = round(0.2*fs)+1 : N-1
val = integ_ecg(i);
if val > integ_ecg(i-1) && val >= integ_ecg(i+1)
if val > threshold_peak && (i - last_r) > refrac
win_start = max(1, i - search_half);
win_end = min(length(filtered_ecg), i + search_half);
win = win_start:win_end;
[max_val, max_idx_in_win] = max(filtered_ecg(win));
r_idx_candidate = win(max_idx_in_win);
if max_val > 0
r_peaks = [r_peaks; r_idx_candidate];
last_r = i;
if val > 3 * peak_level
peak_level = 0.9 * peak_level + 0.1 * val;
else
peak_level = 0.125 * val + 0.875 * peak_level;
end
end
else
noise_level = 0.125 * val + 0.875 * noise_level;
end
threshold_peak = noise_level + 0.25 * (peak_level - noise_level);
threshold_peak = max(threshold_peak, 0.1 * peak_level);
end
end
min_rr = round(0.4 * fs);
if length(r_peaks) > 1
keep = true(size(r_peaks));
for k = 2:length(r_peaks)
if r_peaks(k) - r_peaks(k-1) < min_rr
keep(k) = false;
end
end
r_peaks = r_peaks(keep);
end
cnt = length(r_peaks);
if cnt > best_count
best_peaks = r_peaks;
best_count = cnt;
end
exp_min = max(1, round(N/fs * 0.5));
exp_max = round(N/fs * 3);
if cnt >= exp_min && cnt <= exp_max
best_peaks = r_peaks;
break;
end
end
r_peaks = best_peaks;
% 首尾补齐(简化)
if ~isempty(r_peaks) && N > 2*fs
mean_rr = median(diff(r_peaks));
if isnan(mean_rr), mean_rr = round(0.8*fs); end
if r_peaks(1) > 0.5*fs
search_range = 1:min(r_peaks(1)-1, round(mean_rr));
[max_val, pos] = max(filtered_ecg(search_range));
if max_val > 0
r_peaks = [pos; r_peaks];
end
end
if N - r_peaks(end) > 0.5*fs
search_range = max(r_peaks(end)+1, N-round(mean_rr)) : N;
[max_val, pos] = max(filtered_ecg(search_range));
if max_val > 0
r_peaks = [r_peaks; search_range(pos)];
end
end
r_peaks = unique(r_peaks);
if length(r_peaks) > 1
keep = true(size(r_peaks));
for k = 2:length(r_peaks)
if r_peaks(k) - r_peaks(k-1) < min_rr
keep(k) = false;
end
end
r_peaks = r_peaks(keep);
end
end
if isempty(r_peaks)
[max_val, idx] = max(filtered_ecg);
if max_val > 0
r_peaks = idx;
end
end
end
% ----------------------------------------------------------------------
% 吸附峰值:将每个 R 峰移动到局部窗口内的正向最大值点
% ----------------------------------------------------------------------
function refined_peaks = refine_peaks_to_local_max(ecg, peaks, win_half)
if isempty(peaks)
refined_peaks = [];
return;
end
N = length(ecg);
refined = zeros(size(peaks));
for i = 1:length(peaks)
p = peaks(i);
lo = max(1, p - win_half);
hi = min(N, p + win_half);
[~, maxIdx] = max(ecg(lo:hi));
refined(i) = lo + maxIdx - 1;
end
refined_peaks = unique(refined);
end
% ----------------------------------------------------------------------
% 生成高斯波形(卷积实现,高效)
% ----------------------------------------------------------------------
function gauss = generate_gaussian_wave(ecg, r_peaks, fs)
N = length(ecg);
sigma = 0.05;
half_len = round(3 * sigma * fs);
t_kernel = (-half_len:half_len) / fs;
kernel = normpdf(t_kernel, 0, sigma);
kernel = kernel / max(kernel);
impulse = zeros(N, 1);
impulse(r_peaks) = 1;
gauss = conv(impulse, kernel, 'same');
if max(gauss) > 0
gauss = gauss / max(gauss);
end
end
每一步的输出都传递到下一步,最终生成同名CSV,包含三列:标准化ECG、标准化高斯波形、R峰标记。高斯波形可后续计算心拍,也有更好的可视化效果。
这个系统主要应付以下问题:
(1)噪音导致R峰过于集中,出现误判:
我设计了删除密峰操作

(2)阈值设计不当出现偏差,经我发现这种偏差具有特异性,也许设计的阈值满足了大多数样本,而有的样本就是集体偏移,但一般距离R峰很接近,且偏移很详细,因此我设计了集体左移右移,以及自动在附近吸附峰值点:


此外也可以手动选择,只需要左键点击即可。此外还有一个问题就是病理样本,其幅值异常会导致阈值失效,因此我设计了手动选择基准点(3-15个),之后根据基准点实现重新检测:

具体来讲,先手动选择几个正常峰:

之后点击选择基准峰,将选好的峰值左键点击设置为基准峰:

之后即可重测:
最后点击清楚基准峰即可。
本工具完全由我个人研发,完全开源,大家进行科研时,尤其是涉及心电图标注上可修改后使用,省时省力,我大概一小时校准了约十小时的心电图。