
简介ECG Viewer是一款基于Matlab开发的心电信号查看与分析工具整合数据浏览、滤波去噪、模板匹配、RR间期分析与注释管理全流程面向生物医学工程学习者、临床医生及心电算法研究人员。资源包共18个文件核心为8个M脚本源码搭配2个MAT样本数据、2个mexw32编译模块、1个Access注释数据库和PDF用户手册整体大小2.86MB目录结构清晰便于按功能模块查阅和二次开发。已有3820人学习下载。工具内置心跳检测与信号平滑等可运行算法支持创建自定义模板、检测R波峰值并标记异常RR间期配合注释数据库可记录临床观察结果帮助用户系统掌握从心电信号预处理到特征提取的完整流程为心律失常等研究提供实用的代码参考。1. ECG Viewer 拆解它到底解决了信号处理课设里的哪道坎ECG 和 EKG 是同一个东西的两种写法中文都叫心电图前者是英文缩写后者是德文变体。我拆过的 MATLAB 课设里十个有八个会拿到 ECG Viewer 这个题目把一段心电数据读进来画波形算心率再用 GUI 包起来。听起来一句话的事真做起来一半以上的人卡在同一个地方——波形能画出来但 R 波朝下、T 波被当 R 波、心率在 300 上下乱跳。这份资源走的正是完整链路数据加载、滤波、显示、R 波检测、心率计算、GUI 刷新正好覆盖了「信号处理 界面展示」这一整道课设的坎。适合正在做课程设计需要骨架代码的人也适合想用一个小而完整的例子把 MATLAB 的 figure、uicontrol、filtfilt 这一串工具串起来的人。2. 数据加载与时间轴对齐从 .mat 和 .csv 到第一张能看的图2.1 先摸清数据底细别猜变量名用 fieldnames 和 readtableECG Viewer 的输入数据最常见的两种来源一种是实验室导出的 .mat 文件里面可能存了ecg、val、signal这样的变量名也可能是结构体包了一层另一种是设备直接导出的 .csv有时两列时间、电压有时就一列光秃秃的电压。最蠢也最常见的翻车方式是看到.mat就想当然写load(data.mat); plot(ecg);结果ecg这个变量根本不存在或者存的是别的字段。我在拿到任何 ECG 数据后的第一个动作永远是探查不在变量名上赌运气% 先看 .mat 文件里到底有什么 data load(sample_ecg.mat); disp(fieldnames(data)); % 列出所有字段名 whos(-file, sample_ecg.mat); % 看变量尺寸和类型fieldnames返回的是 cell 数组看清楚字段名再访问。如果加载进来的data本身是结构体里面可能还有一层结构体比如data.annotation、data.signal这种嵌套需要ecg data.(fns{1})先取第一层再用isstruct判断要不要再往下钻。逻辑不复杂但这一步写扎实能省掉后面所有报错。.csv 文件我用readtable而不是csvread因为csvread要求文件里全是数值表头、中文列名、空格都可能导致解析失败。readtable能保留原始列名后面按列名访问更稳T readtable(ecg_record.csv, VariableNamingRule, preserve); disp(T.Properties.VariableNames); % 看列名如果第一列是时间戳第二列是电压直接用T{:, 2}或者按列名取如果只有一列那它就是电压。这个判断交给代码自动做不要靠人工猜。2.2 采样率是命根子时间轴错一位心率全错采样率 fs 是 ECG 数据里最重要的一个元信息。常见取值有 250 Hz、360 Hz、500 Hz、1000 Hz经典开源数据库里的采样率常常是 360 Hz国产可穿戴设备多是 500 或 1000 Hz实验室自己采集的 250 Hz 也很多。fs 一旦写错时间轴按秒算的长度会错心率会成倍错——明明是 60 bpm 的数据fs 传成两倍就变成 120 bpm。时间轴的生成方式永远是这一句t (0:length(ecg) - 1) / fs;length(ecg)是样本点数除以 fs 得到总秒数。比如 250 Hz 下采集 10 秒点数就是 2500t从 0 到 9.996 秒步长 0.004 秒。这里最容易出的问题不是公式而是 fs 从哪里来很多数据文件本身不告诉你采样率只能从文件名、注释或者设备说明书里找。我的做法是把 fs 做成函数的入参外部传进来不写死在函数内部这样至少不会悄悄带错。如果数据是导联系统导出的通常文件名里会带250Hz、500Hz这种标记可以写成一个小解析函数去读文件名如果完全没有元数据就只能根据波形形态猜——正常静息状态心率 60 到 100 bpm画出来看看单个心跳周期占多少像素反推采样率。这一步属于经验活但宁可花十分钟确认也不要带着错误的 fs 往下跑。2.3 一个能扛住两种格式的加载函数 load_ecg.m把上面的逻辑收拢成一个函数是这份资源里最值得直接抄走的一段。它能同时处理 .mat 和 .csv统一输出列向量和时间轴function [ecg, t, fs] load_ecg(filepath, fs) % 读取 .mat 或 .csv 格式的 ECG 数据 % 输出 ecg: 列向量电压(mV), t: 时间轴(s), fs: 采样率(Hz) [~, ~, ext] fileparts(filepath); if strcmpi(ext, .mat) data load(filepath); fns fieldnames(data); ecg data.(fns{1}); % 取第一个字段 if isstruct(ecg) % 结构体嵌套时再进一层 sub fieldnames(ecg); ecg ecg.(sub{1}); end elseif strcmpi(ext, .csv) raw readmatrix(filepath); if size(raw, 2) 1 % 单列就是电压 ecg raw(:, 1); else % 两列及以上默认第二列是电压 ecg raw(:, 2); end else error(不支持的文件格式请先转成 .mat 或 .csv); end ecg ecg(:); % 统一拉成列向量 if nargin 2 || isempty(fs) fs 250; % 兜底值强烈建议外部传入 end t (0:length(ecg) - 1) / fs; endfieldnames(data)返回 cell 数组fns{1}取出第一个字段名再用动态字段访问data.(fns{1})取值嵌套结构体的情况在真实课设里非常多所以isstruct那层判断不能省。readmatrix对 .csv 的容错比csvread好能自动跳过部分非数值内容。nargin判断允许只传文件路径时用默认 fs但默认值只是兜底我真正常用的是load_ecg(data.csv, 500)这种显式传参方式。2.4 加载后第一件事直接画原始波形拿到数据先滤波是很多新手的本能反应但我的习惯是先画原始波形不加任何处理[ecg, t, fs] load_ecg(sample_ecg.csv, 250); figure(Color, w); plot(t, ecg, k-); xlabel(时间 (s)); ylabel(电压 (mV)); title(RAW ECG - 先看原始形态);这一眼要看的是四件事R 波主波方向朝上还是朝下整段基线是不是平缓有没有明显的低频漂移有没有 50 Hz 工频那种细密波纹幅值量级是 mV 还是 µV。这四个观察直接决定后面的滤波参数和要不要做极性翻转。我在复盘时统计过课设里一半以上的「检测不准」问题在这张原始图上就能看出来根本轮不到调检测算法。3. 滤波参数与显示布局从一团乱线到干净的 QRS3.1 三分噪三分滤基线漂移、工频、肌电的频段怎么分心电噪声按频段分三类处理方式完全不同。基线漂移来自呼吸、电极轻微移动能量集中在 0.5 Hz 以下画出来就是整段波形像海浪一样起伏R 波还在但基线不水平。工频干扰是 50 Hz 及谐波波形上表现为细密的锯齿尤其在没接好地线的时候特别明显。肌电噪声来自肌肉收缩频率范围很宽从 30 Hz 一路到几百 Hz视觉上是毛刺感R 波的锐利边缘会被糊掉。用一张表把它们按频段摆开滤波参数就很好定了噪声类型主要频段视觉特征处理手段基线漂移 0.5 Hz整段上下起伏高通 0.5~1 Hz工频干扰50 Hz 及谐波细密锯齿陷波或带通上限压低肌电噪声30~300 Hz毛刺、颗粒感低通 40~70 Hz需要说明的是ECG 的有效频段其实从 0.05 Hz 到 100 Hz 都有能量QRS 波群的主要能量集中在 5~30 HzP 波和 T 波更低。所以滤波不能无脑把高频全砍光砍太多波形是干净了但 P 波 T 波也没了后面想分析形态学就没素材了。3.2 定一组能用的滤波器参数butter filtfilt 的黄金组合MATLAB 里做心电滤波我最常用的组合是butter设计低阶巴特沃斯滤波器再用filtfilt做零相位滤波。选低阶2 阶或 3 阶是因为高阶滤波器在截止频率附近会产生振铃振铃叠加在 ECG 上会在 QRS 前后制造假的波动检测算法很容易把这些波动当成峰。filtfilt和filter的差别在于filtfilt对信号做了正向加反向两次滤波相位延迟互相抵消输出信号和原始信号在时间轴上不产生偏移。一组能直接用的参数是这样fs 250; % 采样率必须和数据一致 f_low 0.5; % 高通截止 0.5 Hz压掉基线漂移 f_high 70; % 低通截止 70 Hz保留 QRS 形态 [b, a] butter(2, [f_low f_high] / (fs / 2), bandpass); ecg_f filtfilt(b, a, ecg);butter的截止频率参数要除以奈奎斯特频率fs/2也就是说在 250 Hz 采样率下70 Hz 对应的归一化频率是 70/125 ≈ 0.56。[0.5 70]/(fs/2)整体小于 1bandpass类型才能正常工作。如果 fs 变了这段参数必须跟着重算这就是为什么采样率要明确定义而不是写死。f_high 设 70 Hz 是我用来做显示的常用值能保住 QRS 的锐利上升沿也让波形看起来不那么糊。如果后面只想做 R 波检测低通上限甚至可以压到 40 Hz检测更稳但显示时波形会偏钝。同一个工程里显示和检测往往需要两套不同的滤波参数不要混着用。滤完之后如果发现波形出现振铃把阶数从 2 降到 1或者把截止频率放松一点能明显改善。3.3 显示布局subplot 排布与波形更新方式ECG Viewer 的界面通常需要在同一个窗口里放原始波形和滤波后波形我用subplot垂直排布上面是原始信号下面是滤波后的信号R 波检测结果标在下面那张图上figure(Color, w); subplot(2, 1, 1); plot(t, ecg, b-); ylabel(RAW (mV)); title(原始信号); subplot(2, 1, 2); h_ecg plot(t, ecg_f, k-); hold on; h_r plot([], [], ro); % R 波标记先占空 ylim([-1 1.5]); xlabel(时间 (s)); ylabel(滤波 (mV)); title(滤波后 R 波); xlim([0 10]); % 默认窗口显示前 10 秒上下两幅图必须共享时间轴xlim的取值保持一致对比才有意义。这里我用到plot([], [], ro)先生成一个空的 R 波标记对象后面刷新时只改它的XData和YData。这个方法在 GUI 里极其重要后面第 4 章会展开讲。ylim([-1 1.5])这种手动范围要按实际幅值调如果信号幅值是几个 mVY 轴范围写太小会把波形截顶。我一般先ylim auto看一眼全貌再手动收紧到适合观察的范围。3.4 手动缩放与滚动让 Viewer 有「看」的体验一个 ECG Viewer 如果只能看到整段 10 秒波形那它的可用性基本为零。真正要看的是单个心跳的细节所以我至少会做两件事鼠标滚轮缩放和拖动平移。MATLAB figure 自带的zoom on能解决缩放但滚轮缩放经常把 XY 轴一起缩放反而不好用。我常用的是自己写两个按钮一个放大一个缩小逻辑固定地只改当前显示窗口的xlimfunction zoom_in(~, ~) lim xlim; span lim(2) - lim(1); xlim([lim(1) span * 0.1, lim(2) - span * 0.1]); end这样缩放的中心是当前坐标轴的中心点比例固定 20%不会出现鼠标滚轮那种突然不知道怎么跳到哪里的情况。平移我一般不做按钮因为波形较长时加一个滑块更直观。滑块的值映射到时间窗口的起点每次滑动就set更新上下两个axes的XLim这样比拖动窗口内容省很多事。4. R 波检测与心率计算简化版 Pan-Tompkins 实战4.1 为什么不能直接在原始信号上设阈值很多人的第一反应是在原始波形上找一个阈值高于阈值就是 R 波。这个思路在一段非常干净的短数据上能跑通但换一段数据就翻车。两个典型场景T 波的幅值在某些导联上可以接近甚至超过 R 波全局阈值直接失效基线漂移会把 R 波抬高或压低固定阈值要么漏掉低幅度的 R 波要么把漂移的斜坡误判出来。所以检测必须在预处理之后的派生信号上做而不是在原始信号上做。经典的 Pan-Tompkins 算法把 ECG 变成一种「每个心跳对应一个显著峰」的信号步骤是带通滤波、差分、平方、滑动窗口积分最后在积分信号上找峰。差分突出斜率QRS 的斜率是全周期里最陡的平方让所有值变正并把大幅值和小幅值差距拉大滑动窗口积分把一个尖锐的峰在时间上展宽方便找到稳定的局部极大值。4.2 简化检测流程带通滤波、差分、平方、滑动积分五步走完整实现 Pan-Tompkins 有自适应的阈值更新机制代码量大。课设和工具场景下我通常用一个简化版本处理单段静息数据已经足够稳function [idx, hr] detect_ecg_peaks(ecg, fs) % 简化版 R 波检测带通滤波 - 差分 - 平方 - 滑动积分 - 找峰 % idx: R 波峰值所在样本序号 % hr: 平均心率(bpm) [b, a] butter(2, [5 15] / (fs / 2), bandpass); ecg_f filtfilt(b, a, ecg); diff_sig diff(ecg_f); % 一阶差分突出斜率 sq diff_sig .^ 2; % 平方放大主波 win round(fs * 0.15); % 150ms 滑动积分窗 integ movmean(sq, win); [~, idx] findpeaks(integ, ... MinPeakHeight, 0.6 * max(integ), ... MinPeakDistance, round(fs * 0.4)); idx idx 1; % diff 少一个样本补偿偏移 rr diff(idx) / fs; % R-R 间期(秒) hr 60 / median(rr); % 中位数心率 end带通选 5~15 Hz 是为了只保留 QRS 的主要频带把 P 波、T 波和大段基线都给滤掉。diff之后信号长度比原来少 1所以后面findpeaks找到的位置要 1 补偿否则检测到的 R 波位置在原始时间轴上会偏一个采样点这个偏移平时看不出来但一旦要标注到原始波形上对不齐就会很明显。findpeaks的两个参数是这套检测的精髓。MinPeakHeight取积分信号最大值的 60%这个阈值对一段相对稳定的数据够用但遇到前半段和后半段幅值差异大的情况就会偏。更稳的做法是在滑动窗口内取局部阈值为窗口峰值的百分比比如每 2 秒一个窗口这种自适应阈值实现也不复杂。MinPeakDistance设成 0.4 秒也就是 150 bpm正常成人 R-R 间期不会短于这个值它能把 T 波误检直接挡掉。4.3 心率用中位数而不是平均值心率从 R-R 间期算出来常规写法是hr 60 / mean(rr)但我的习惯是用median(rr)。原因很实际如果检测算法偶发漏掉一个 R 波那个 RR 间期会变成正常值的两倍平均心率会被这个异常值明显拉低如果误检一个 T 波RR 间期又会短一半平均心率被拉高。中位数对这类离群点完全不敏感五个 R-R 间期里有一个异常值中位数基本不变而均值已经偏了。计算完还可以顺手算一个简单的心率变异性指标sdnn std(rr); % 单位秒正常静息时一般在 0.02~0.06 之间SDNN 是 RR 间期序列的标准差反映心率波动。做课设写到这一步论文里的分析内容就多了。「心率 SDNN 波形」三件套已经是一个能交差的指标组合了。4.4 把检测结果挂到 GUI 上set 更新而不是反复 plot在 GUI 里刷新波形最常见的坏习惯是每次更新都调用一次plot。plot会创建新的图形对象旧的图形对象被覆盖但没完全销毁循环跑几百次之后 figure 里对象数量爆炸拖动窗口和刷新都会卡到没法用。正确做法是初始化时把图形对象的句柄存下来之后只改它的数据属性% 初始化时创建对象 subplot(2, 1, 2); h_ecg plot(t, ecg_f, k-); hold on; h_r plot([], [], ro, MarkerSize, 8); % 每检测完一段数据只更新 YData 和 XData idx_win idx(idx fs * 10); % 只显示前 10 秒内的 R 波 set(h_r, XData, t(idx_win), YData, ecg_f(idx_win)); set(h_ecg, YData, ecg_f);set只改属性不创建新对象刷新效率比plot高一两个数量级。如果你在一段 10 秒的循环里用plot重画 50 次还不觉得卡换成 60 秒数据试试就明白了。这也是我说 GUI 卡顿大多是「用错了更新方式」而不是 MATLAB 性能差的原因。做实时显示时控制刷新频率也重要常见做法是每 2 秒刷新一次窗口不要让循环跑满 CPU。5. 避坑ECG Viewer 最常见的五个翻车点5.1 R 波全部朝下极性翻转问题现象加载数据后画出来R 波主波方向朝下整个波形像是倒扣的后续检测算法怎么调阈值都找不准位置。原因多数是导联接反了或者设备记录时本身把信号反相存盘。检测算法在积分信号上其实不区分方向但在显示和人工标注时倒置波形看着极度别扭而且会让依赖固定方向假设的算法误判。解决在代码里加一个开关检测 R 波主波方向。常见做法是取一段信号的差分最大值和最小值比较绝对值大小pole sum(diff(ecg_f) 0) / length(ecg_f); % 斜率正负比例 if pole 0.4 ecg_f -ecg_f; % 主波在下翻转 end这个比例阈值不精准但对绝大多数静息数据够用。更稳的做法是在界面上放一个「翻转」按钮让用户自己判断程序别自作主张。5.2 滤波后 R 波变矮截止频率与阶数的取舍现象滤波后的波形看起来干净了很多但 R 波明显变矮变钝检测结果总是漏掉几个低幅值的波峰。原因低通截止频率设太低或者滤波器阶数太高。QRS 的高频锐利边缘被削掉峰顶被磨平给后续差分检测的信号斜率不足。f_high 设到 30 Hz 以下就会出现这种情况4 阶以上的 Butterworth 也会在通带内制造不必要的衰减。解决把低通截止频率放宽到 50~70 Hz阶数降回 2 阶。记住显示和检测可以用不同的参数检测时低通设 40 Hz 足够显示时为了形态保真可以设到 70 Hz。改完参数后重复一个动作画原始和滤波的叠加图看 R 波峰顶是否被削。5.3 心率在 300 bpm 上下乱跳T 波被当成了 R 波现象检测出的心跳数远多于实际心率值在 300 甚至更高处乱跳波形图上两个相邻标记间隔特别短。原因阈值太低或者没有做最小间距限制。T 波在积分信号上的峰值如果超过阈值就会被当成一个心跳。还有一种情况是 50 Hz 工频没滤干净每半个周期都产生一个虚假峰。解决检查两个findpeaks参数。MinPeakDistance设为 0.4 秒400ms对应 150 bpm 上限MinPeakHeight从 0.6 倍抬高到 0.8 倍有时也能挡住。工频干扰则要回到滤波环节如果基线看起来有细密锯齿给滤波器加一个 48~52 Hz 的陷波段或者把低通上限压到 40 Hz。5.4 拖动界面就卡死每帧都在创建新的图形对象现象程序跑起来窗口也能出波形但一旦拖动窗口、缩放或者刷新界面卡得像是死机CPU 占用率接近 100%。原因在循环或回调里反复调用plot每次调用都在 axes 上增加一个图形对象。对象的数量随着刷新次数增长重绘开销越来越大最终整个 figure 的重绘逻辑崩溃。解决初始化时用plot创建对象并保存句柄之后一律用set(h, XData, ...)更新数据。一句话原则循环里不要出现plot三个字母这条规则能解决 80% 的 MATLAB GUI 卡顿问题。5.5 时间轴对不上采样率被写死现象波形显示出来横轴秒数明显不对或者心率算出来是真实值的两倍检查了所有代码逻辑都没发现错误。原因fs 被写成常数。最典型的是在代码里写死fs 250换了一批 500 Hz 的数据忘改或者加载函数用的默认 250 Hz 兜住了真实采样率。解决把 fs 变成加载函数的入参并从文件名、数据注释、设备说明书里确认后再传入。数据文件多的时候在加载函数里打印一行确认信息fprintf(fs %d Hz, 信号长度 %.2f s\n, fs, length(ecg) / fs);打印出来的人工确认习惯比任何默认值都可靠。6. 进阶使用形态学标注与批处理导出让 Viewer 真正可用6.1 把 R 波标到波形上检测完成后把结果可视化是必要的验证步骤。不要只看到一个心率数字就收工把每个 R 波用红圈标在滤波波形上一眼就能看出哪些是误检哪些是漏检这是我最常用的验证手段hold on; plot(t(idx), ecg_f(idx), ro, MarkerSize, 10, LineWidth, 1.5);如果红圈像项链一样均匀串在 QRS 波群上检测基本可信如果某些红圈落在 T 波上或者中间明显漏了一段就要回头调MinPeakHeight和MinPeakDistance。形态学分析也可以顺带扩展P 波定位在 R 波前 200ms 附近找局部极大值T 波终点在 R 波后 300ms 范围内找拐点这些功能加进去Viewer 的深度立刻不一样。6.2 批处理与结果导出对一个文件夹里的几十段数据逐段跑检测并汇总结果是课设答辩前的刚需操作files dir(fullfile(data, *.csv)); results table(); for k 1:length(files) fp fullfile(files(k).folder, files(k).name); [ecg, t, fs] load_ecg(fp, 360); [idx, hr] detect_ecg_peaks(ecg, fs); results [results; table({files(k).name}, hr, ... VariableNames, {File, HR})]; end writetable(results, hr_results.csv);dir拿到的文件列表带 folder 和 name 两个字段拼接路径时用fullfile比手动加斜杠安全Windows 和 macOS 下都不会出错。writetable导出的 CSV 可以直接用 Excel 打开汇总表里再附上 SDNN、误检率这些统计量报告的数据部分就齐了。从那以后我每次拿到新的 ECG 数据都强制先走一遍「加载函数 打印 fs 画原始波形」三板斧确认数据形态没问题再谈检测算法这个习惯帮我避开了后续一大半的玄学问题。希望帮到你。本文还有配套的精品资源点击获取