ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MIT-BIH心电数据解析与MATLAB绘图:解开212位交错格式的完整指南

MIT-BIH心电数据解析与MATLAB绘图:解开212位交错格式的完整指南

简介:MIT-BIH ECG数据库由麻省理工学院与波士顿医院联合发布,是心律失常研究领域的经典数据源。面向心电信号处理初学者和生物医学工程相关专业学生,压缩包内含.mat格式的心电数据、可运行的MATLAB绘图脚本(.m文件)、配套信息文件与自动备份文件,共4个文件、约20KB。利用附带脚本可直接绘制心电波形,清晰观察P波、QRS复合波与T波等关键特征,并结合采样率与奈奎斯特定理理解数据特性,同时掌握MATLAB处理生物医学信号的典型流程,为后续心率、心律和QT间期等指标分析提供直观入口。资源已有7597人浏览/学习,适合用于课堂演示、课程设计或入门自学,程序注释简洁、结构清晰,便于读者按步骤修改参数并观察波形变化,具有较强的实操参考价值。

1. MIT-BIH ECG 心电数据:为什么画图前必须先解开 212 位交错格式

下载过 MIT-BIH 心电数据的人多半有过类似经历:兴奋地打开 .dat 文件,看到的不是波形,而是一堆 0 到 255 之间的二进制乱码。ECG 算法研究绕不开这套数据库,它的注释文件里存着几十位专家逐拍标定的心拍类型,是目前心律失常检测领域的事实基准;但真正的门槛不在算法,而在第一步——把这堆字节还原成电压信号,再落到 MATLAB 绘图里。整个过程牵涉三种文件协议、两层位运算、四层单位换算,任何一层出错,画出来的就是一张形似心电图的废纸。这篇就拆解这条从原始二进制流到毫秒级波形的完整链路,搞清楚为什么这套四十年前的格式至今仍是 ECG 绘图的事实标准。

2. 三个文件三种协议:头文件、数据文件、注释文件如何拼出一份心电记录

一套完整的 MIT-BIH 记录由三个文件构成:.hea、.dat、.atr。它们各司其职,却共享同一个信息模型:一条记录是多个通道的连续采样序列,采样点从 0 开始编号,注释文件里的每个 R 峰位置都指向这个采样编号轴。绘图时你需要的全部信息,几乎都要先解析这三个文件才能拿到。

2.1 头文件 .hea:整个解析流程的元数据契约

.hea 是纯文本,前几行定义了之后所有解码所需的参数。典型内容如下:

100 2 360 650000 100.dat 212 200 1024 11 n/s 100.atr 212 200 1024 11 n/s

第一行的字段依次是:记录名、通道数、采样率(360 Hz)、总采样点数(约 30 分钟)。下面每一行对应一个通道文件:文件名、存储格式(212)、ADC 增益(200)、基线(1024)、采样位数(通常 11 或 12)、单位。基线 1024 意味着裸 ADC 计数值 1024 对应 0 mV;增益 200 表示每个 ADC 计数对应 1/200 mV,也就是 5 微伏每 LSB。

MATLAB 解析这段文本非常直接:

fid = fopen('100.hea', 'r'); line1 = fgetl(fid); % 记录名 / 通道数 / 采样率 / 采样点数 meta = textscan(fgetl(fid), '%s %d %d %d %d %s'); % 文件名 格式 增益 基线 位数 单位 fclose(fid); fs = 360; % Hz,后续时间轴换算的锚点 gain = meta{3}; % 200,ADC 计数到 mV 的除法因子 base = meta{4}; % 1024,零电压对应的基线

这里我一般会先打印一遍 meta 再往下写,因为不同记录文件的第二行字段顺序完全一致,但 gain 和 baseline 可能不同。把这两个值硬编码进脚本是常见的翻车方式,后面实测某条记录时波形的直流偏置会整体抬高或压低。

2.2 数据文件 .dat:212 格式的三字节交错机制

.dat 是二进制,这是整个流程里最反直觉的部分。每个采样点本身只占 12 位(取值范围 -2048 到 2047),但文件系统是字节寻址的,三个字节装不下两个 12 位整数——于是原始设计者选择了交错布局:3 个字节依次保存 通道 A 的 12 位、通道 B 的 12 位,一次覆盖两个采样点。

位布局如下:

  • 第 0 字节:通道 A 的低 8 位
  • 第 1 字节:通道 A 的高 4 位(低半字节)+ 通道 B 的高 4 位(高半字节)
  • 第 2 字节:通道 B 的低 8 位

解析代码对应地也分为三步:

fid = fopen('100.dat', 'r'); raw = fread(fid, 'uint8'); fclose(fid); raw = reshape(raw, 3, [])'; % 每 3 字节一组 sA = raw(:, 1) + bitshift(bitand(raw(:, 2), 15), 8); sB = bitshift(raw(:, 2), -4) + bitshift(raw(:, 3), 8); sA = sA - (sA > 2047) * 4096; % 12 位二进制补码的符号扩展 sB = sB - (sB > 2047) * 4096; v1 = (sA - base) / gain; % 单位:mV v2 = (sB - base) / gain;

两个关键参数值得细说。bitshift(bitand(raw(:,2), 15), 8)先取第 1 字节的低 4 位,再左移 8 位,拼上第 0 字节,得到通道 A 的 12 位原码值;通道 B 则是取第 1 字节的高 4 位右移后,拼上第 2 字节整体左移 8 位的结果。第二步的s > 2047是符号扩展:12 位补码里最高位为 1 时代表负数,需要减去 4096 才能还原真实的生理信号幅值,漏掉这一步是心电绘图里最典型的“看起来像锯齿波”的原因。

提示:fread 读入的是无符号字节,所有位运算都要放在 uint8 或转 double 之后做,否则 MATLAB 会报数据类型错误。

2.3 注释文件 .atr:把 R 峰位置和类型编码进时间戳

.atr 是专家标注的二进制协议文件,记录了每一次心拍的类别和位置。和 .dat 一样,它的设计目标是极端紧凑:普通注释只占两个字节。第一个字节的低 6 位是相对于上一个注释的时间偏移(单位是采样点),第二个字节的低 6 位是心拍类型编码。如果偏移为 0,则需要额外两个字节构成 14 位扩展偏移;读到 0xFF 表示注释段结束。

R 峰位置的计算方式是累计求和:每个注释的绝对位置等于之前所有偏移之和。画图时除以 360 得到秒。这段解码逻辑不长,但容易写错:

atr = fread(fopen('100.atr','r'), 'uint8')'; pos = []; typ = []; acc = 0; k = 1; while k < numel(atr) if atr(k) == 0 off = atr(k+1) + 256*atr(k+2); % 扩展偏移 k = k + 3; elseif atr(k) == 255 break; % 结束标记 else off = bitand(atr(k), 63); % 低 6 位才是偏移 k = k + 1; end if k > numel(atr), break; end tcode = bitand(atr(k), 63); % 低 6 位是类型 k = k + 1; acc = acc + off; pos(end+1) = acc; typ(end+1) = tcode; end

这段代码里最容易挖的坑在bitand(atr(k), 63):类型字节的高 2 位被设计者用来放标测标记和校验信息,不参与心拍类型定义。如果不做掩码直接取整字节,所有正常心拍的 0 型标注会变成 64、65 这类“不存在”的类型,绘图时注释点要么消失要么全被归到未知类别。解码完成后,pos的单位是采样点,不是秒——这是下一章时间轴换算的起点。

3. MATLAB 里的四层换算:从 ADC 计数到毫秒级波形

拿到解码后的信号序列,离绘图还差四步换算:采样点到时间、ADC 计数到毫伏、两通道同步、注释类型到可读标签。每一步都直接影响波形呈现的临床意义。

3.1 时间轴与单位:ADC 计数如何变成微伏电压

时间轴换算最简单,也最容易被忽略。360 Hz 采样率的意思是每 1/360 秒采一次样,30 分钟共约 65 万个点。生成时间向量只需一行:

t = (0 : numel(v1)-1) / 360; % 单位:秒

为什么要强调除以 360 而不是用索引当横轴?因为只有换算成秒后,QRS 波的宽度(正常 80~100 ms)、RR 间期(约 0.6~1.0 s)才能对照临床标准做视觉判断。直接画索引时,一个 QRS 波宽约 30~36 个采样点,看起来和 0.3 秒的药物干预后宽 QRS 完全区分不开。

单位换算则在上一章已经完成:v = (s - base) / gain。其中 base 是零电压对应的 ADC 计数,gain 是灵敏度。默认记录里 gain 为 200,即 1 mV 对应 200 个计数,逐点换算后波形纵轴就是毫伏,可以直接和标准心电图纸的 10 mm/mV 刻度对比。这里有个常见的认知误差:ADC 位数是 11 位,存储用 12 位补码,但数字分辨率不等于幅度分辨率,决定纵轴物理意义的是gain,而不是存储位宽。

3.2 通道与导联:两路信号为什么必须同步显示

MIT-BIH 记录的标准配置是两通道,第一通道通常是 MLII 导联(改良肢体 II 导),第二通道多为 V1 或 V5。两个通道的时间轴完全同源,因为 .dat 文件里本来就是按采样点交替存储的。绘图时将两个通道放到上下两张子图里,共享 x 轴:

figure('Units','centimeters','Position',[1 1 17 8]); ax1 = subplot(2, 1, 1); plot(ax1, t, v1, 'k'); ylabel(ax1, 'MLII (mV)'); ax2 = subplot(2, 1, 2); plot(ax2, t, v2, 'k'); ylabel(ax2, 'V5 (mV)'); linkaxes([ax1 ax2], 'x');

linkaxes(..., 'x')保证 zoom 时两边同步平移,这样观察某个 PVC 在两条导联上的形态差异时,不会因为手动滚动而错位。第二通道的价值在于:同一心拍在 MLII 上可能因为主波方向不明显而漏判,但 V5 上的形态却清晰表现出室性早搏的宽大畸形,所以在绘图阶段就保持两通道对齐是后续诊断分析的基础。

3.3 注释叠加:把专家标定画到波形上的查表逻辑

注释解码出来的是数字类型码(0、1、2、3、4...),直接散点图只会得到一堆无法区分的点。绘图前需要建一个类型映射表,再在对应位置上按颜色散点叠加。常见的类型编码含义如下:

类型码含义建议显示颜色
0正常心拍黑色 / 绿色
1左束支阻滞蓝色
2右束支阻滞青色
3室性早搏红色
4房性早搏洋红色
5起搏心拍黄色

叠加逻辑放在解码之后:

type_names = {0,'正常'; 1,'LBBB'; 2,'RBBB'; 3,'PVC'; 4,'PAC'; 5,'起搏'}; hold(ax1, 'on'); valid = ismember(typ, [0 1 2 3 4 5]); scatter(ax1, pos(valid)/360, v1(pos(valid)+1), 12, ... cellfun(@(c) mapColor(c), num2cell(typ(valid))), 'filled');

这里注意v1(pos+1)的下标偏移:解码后的位置是 0-based 采样索引,MATLAB 数组是 1-based。少加这个 1,所有注释点会整体左移一个采样点,肉眼不易察觉,但对齐标注时始终差 2.8 毫秒,属于最折磨人的隐性错误。注释叠加不仅是事后可视化,它让你能一眼看出当前波形段里有多少心拍被专家归为异常,也是后文做“事件密集区定位”的前提。

4. 呈现一份能看清细节的心电全景图

解码和换算完成之后,绘图本身还面临一个矛盾:一份记录有约 65 万个采样点,显示器的横向分辨率只有一两千像素,直接plot(t, v1)必然糊成一团黑色色带。这个问题的答案不是改变绘图命令,而是改变显示策略:事件区用定长窗口放大,长程段用包络压缩。

4.1 动态段选择:用注释文件定位事件密集区

有了注释位置pos,你可以计算出每个心拍的 RR 间期:

rr = diff(pos) / 360; % 相邻心拍间隔,单位秒 eventIdx = find(rr < 0.5); % 找短 RR 间期所在位置

把短 RR 间期对应的注释点附近作为一个“可疑事件区”,然后以该位置为中心,截取左右各 5 秒:

center = pos(eventIdx(1)); seg = center + (-5*360 : 5*360); plot(t(seg), v1(seg), 'k');

选择 10 秒窗口不是拍脑袋。心电图纸的标准走纸速度是 25 mm/s,一屏恰好显示 10 秒,这个约定被临床医生和算法研究者共享。当你把某个 PVC 的片段用 10 秒窗画出来,QRS 宽度、ST 段形态、代偿间歇是否完全,都能在同一种视觉规范下被讨论。pos数组在这里充当了目录:不需要人工拖动整条记录去“偶遇”一个早搏。

4.2 长程压缩显示:避免一页纸糊成色带的包络方案

如果你想在一张图里纵览 30 分钟的心律趋势,直接画 65 万点是一条死路。2600 个点对应一个像素列时,点的叠加重合会让波形变成实心矩形。一个有效的替代方案是“极值包络”:把信号切成与像素列数量等长的块,每个块取最小值和最大值各画一个点,得到的带状区域保留了波形的最大动态范围。

px = 1800; % 目标像素列数 n = numel(v1); blk = floor(n / px); segm = reshape(v1(1:blk*px), blk, []); envL = min(segm); % 下包络 envH = max(segm); % 上包络 envT = (0:px-1) * blk / 360; % 包络点的时间坐标 plot(envT, envL, 'k', envT, envH, 'k');

包络带的意义在于:当某个时间段出现连续室早,带状区域会突然增宽;当信号丢失或导联脱落,带子会瞬间变成一条平坦线。和直接绘制下采样均值相比,包络不会丢掉瞬时尖峰的信息——PVC 的高幅值、宽大 QRS 正是靠在包络带边缘的凸起被发现的。如果你需要对长程 RR 间期做趋势分析,更精细的做法是对 RR 间期本身画散点,而不是对原始波形做压缩。

4.3 输出与版式参数:让波形图达到印刷质量

科研绘图和屏幕预览不同,落地到论文或报告里要考虑物理尺寸、字号和 DPI。下面这组参数是我在实际出图时固定使用的:

参数项建议值说明
图宽17 cm适配双栏排版
图高6~8 cm两通道时取高值
字号8~9 pt印刷体偏小,屏幕预览偏大
导出格式PNG + PDF位图方便嵌入,PDF 保证矢量清晰
分辨率300 DPI满足期刊最低要求
单屏时间窗10 s对应临床标准的走纸速度 25 mm/s

MATLAB 里用figure('Units','centimeters','Position',...)设尺寸,用exportgraphics导出:

exportgraphics(gcf, 'ecg_pvc.png', 'Resolution', 300); exportgraphics(gcf, 'ecg_pvc.pdf', 'ContentType', 'vector');

PDF 矢量导出的优势在缩放大 QRS 细节时尤其明显:放大任意倍数线条边缘都不会出现像素锯齿,这对审稿人放大检查 ST 段形态非常友好。

5. 绘图排雷手册:五个案例让你少走三年弯路

基于手动解码和绘图过程中最常见的失败模式,整理出五条具体的踩坑记录。每条都按“现象 → 原因 → 解决”展开,可以当作脚本出问题时的对照清单。

5.1 波形整体漂移,QRS 宽度看起来不对

现象:ST 段斜率异常,QRS 宽度几乎是正常值的两倍,心搏形态“变形”。原因:时间向量把采样率写成了 250 或直接把索引当秒,导致横轴拉伸或压缩,波形形态失真。解决:回到头文件确认采样率,用t = (0:n-1)/360重新生成时间轴,再检查 10 秒窗口内是否恰好有 3600 个采样点,如果对不上就是采样率或窗口长度有问题。

5.2 信号只有 0 和 -4096 两种值,看起来像方波

现象:波形失去心电特征,呈现大幅跳变的锯齿状。原因:漏掉了 12 位补码的符号扩展,s > 2047的数没有减去 4096,所有负电压被映射成了接近 4096 的正值。解决:在完成位拼接后立刻执行s = s - (s > 2047) * 4096,再做基线扣除。

5.3 R 峰注释点比实际 QRS 早或晚约一个心拍

现象:散点标注的位置和波形上的 QRS 主波明显错位,偏差在几百毫秒量级。原因:把注释偏移当成了秒,或解析偏移时没有做低 6 位掩码,导致位置累计错误。解决:先取pos(1)与第一段波形里肉眼可辨的 QRS 峰值对照,确认pos是采样点后统一除以 360 再画图。

5.4 两通道波形互相“串门”:第二通道出现了第一通道的 QRS

现象:上下两张子图波形形态几乎相同,或幅值、相位错乱。原因:reshape(raw, 3, [])之后通道拆分方向写反,通道 A 和通道 B 的位拼接顺序对调。解决:打印前 20 个采样点的拼接结果,与头文件里的通道顺序核对;更稳妥的做法是先用已知形态的短片段验证两个通道的 QRS 主波方向一正一负。

5.5 所有注释类别都显示为 0(正常)

现象:颜色散点全是一种颜色,异常心拍无法区分。原因:类型字节没有用bitand(..., 63),把包含标测信息的完整字节当成类别编码。解决:类型和偏移都只取低 6 位,其余高位全部丢弃,并在查表前对类型码去重,看看是否有超出 [0,5] 的脏值。

6. 数据格式的复古之美:十二位比特串为何至今没被淘汰

回看整条链路,最值得琢磨的不是 MATLAB 代码,而是 212 格式本身的设计取舍。它当年为磁带记录仪和低速串行传输而生,采样的存储密度被压到极致:12 位信号塞进 3 字节,通道交错让两路信号可以顺序从磁带上流式读出,无需跳转。作为代价,解析时必须手动做位拼接、符号扩展、掩码,这在今天的开发者看来极不友好。但它活到了现在,且依然是 ECG 算法评测的事实标准,原因有三个:紧凑(一套 30 分钟双通道记录只有约 2 MB)、可流式解码(边读边画,不用等全量数据)、近似随机访问(知道采样点编号就能算字节偏移)。相比之下,任何自描述格式如 HDF5 或 JSON 都会把同等数据的体积放大数倍,而对医学数据集来说,几十万份记录的存储和传输成本是实实在在的约束。

绘图惯例同样保留着临床时代的印记:10 秒窗口、25 mm/s 走纸速度、标准网格背景。算法研究者即使不做临床诊断,也会沿用这套窗口来保证自己的可视化结果能被医生直接理解。这意味着可视化的本质不只是观察数据,而是选择一种观察协议——你画图的窗口长度、时间轴刻度、导联标注方式,都在隐式地告诉读者“这段信号应该怎么被解读”。

我自己的习惯是:拿到任何一份 ECG 数据,先强制走一遍解码自查——读头文件打印增益和基线、验 10 秒窗口内的采样点数、取一个已知心率片段与注释位置比对,确认工具链完全连通后,才开始画正式的图。这套流程解决过很多次把 250 Hz 当 360 Hz 画出一张假 ST 段抬高的尴尬。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进