基于MATLAB的ECG R峰自动检测与交互式校正系统

在心电信号处理中,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个),之后根据基准点实现重新检测:

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

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

之后即可重测:
最后点击清楚基准峰即可。

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

相关推荐
IT猿手11 小时前
光伏模型参数估计:山羊优化算法(Goat Optimization Algorithm, GOA)求解光伏模型参数辨识问题,免费提供完整MATLAB代码链接
开发语言·算法·matlab·智能优化算法·光伏模型参数估计·光伏模型参数辨识·最新群智能算法
ytttr87316 小时前
惯性导航精解算程序(MATLAB实现)
开发语言·matlab
jghhh011 天前
认知无线电中基于能量检测的双门限频谱感知的 MATLAB 仿真
开发语言·matlab
BT-BOX1 天前
Matlab 2025B下载安装教程
开发语言·matlab
机器学习之心1 天前
多工况车速数据集训练LSTM-Attention用于车速预测,输出未来多个时间步车速,MATLAB代码
人工智能·matlab·lstm·lstm-attention·车速预测
Evand J1 天前
MATLAB绘图函数介绍:plotmatrix绘图,附MATLAB例子
开发语言·matlab·绘图
rit84324991 天前
基于遗传算法的电动汽车充电站选址优化:模型与MATLAB实现
开发语言·matlab
feifeigo1231 天前
自适应大邻域搜索(ALNS)算法的MATLAB 实现
开发语言·算法·matlab
fengfuyao9851 天前
5G网络场景MATLAB仿真方案
matlab