前几篇聊了心电信号产生的电生理基础、噪声的主要来源,以及预处理里那些看着简单实则容易翻车的细节。今天这篇想聊一个特别基础、但又特别容易被忽略的问题:心电数据格式。
之所以把数据格式单拎出来放一篇,是因为我在实际项目里见过太多次这样的情况:算法模型在公开数据集上跑得好好的,换成另一批数据就各种对不上;指标怎么调都上不去,最后排查了一两周,发现是最开始的读取环节出了问题。心电数据格式是整个信号处理链路的第一道关卡,它直接决定后面所有去噪、特征提取、心拍检测的输入到底是不是对的。做心电信号处理的人,无论你是搞算法的、做嵌入式采集的,还是搞医学信息系统的,这件事都绕不开。
1. 为什么读心电数据比想象中更容易出错
1.1 我第一次拿到MIT-BIH数据时的真实状态
刚入行那会儿,我第一次下载MIT-BIH心律失常数据库,解压之后看到一堆.hea、.dat、.atr文件,整个人是懵的。那时候网上能找到的资料大多只说"用WFDB工具包读就行了",但工具包装好之后,rdsamp到底输出的是什么?单位是mV还是ADC码值?为什么打印出来的数值有正有负有的还特别大?这些问题问了很多人,得到的答案都是一句"你自己看文档"。
后来硬着头皮啃文档,才慢慢搞清楚:这一堆后缀名其实是一套完整的数据组织方式,每个字段都有它存在的意义。而在那之前,我已经用错误的方式写了不少"看起来能跑"的代码,现在回头看,基本都是靠试错试出来的。
1.2 数据格式为什么能决定一条处理链路的成败
心电数据的核心是由离散采样点组成的波形序列,这本质上不复杂。但落到具体文件里,情况就五花八门了:有的格式把波形直接存成文本,每行一个数值,简单直观;有的格式为了节省存储空间,把模拟信号经过ADC量化后压缩成二进制位流,比如MIT-BIH常用的212格式,需要你手动处理位运算;有的格式把心电信号和事件标记、诊断结论、患者信息揉在一个复杂结构里,比如SCP-ECG。
不同格式背后是不同行业阶段和不同应用场景的产物,也意味着不同的解析成本。很多人以为"反正都是心电数据,读出来都一样",但实际上单位换算、基线处理、时间轴还原、标注通道对齐,每一步都可能埋雷。格式没读对,后面所有分析都建立在沙子上——更麻烦的是,这种错误不会让程序崩溃,它只会让你的实验结果看起来"有点怪",但很难定位到底哪里错了。
2. 绕不开的WFDB三件套:MIT-BIH记录的文件结构
聊心电数据格式,第一个绕不开的就是MIT-BIH心律失常数据库。原因很简单:大量论文、开源代码、基准评测都基于这个库,它几乎成了心电算法圈的公共语言。理解它的存储结构,等于拿到了解读一堆其他格式的钥匙。
2.1 头部文件(.hea):整条记录的自白书
MIT-BIH数据库中的每条记录由三个文件组成:.hea头部文件、.dat数据文件、.atr注释文件。三件套缺一不可。
.hea是纯文本文件,是整个记录的自白书。它告诉你这条记录叫什么名字、有几个导联、采样率是多少、数据以什么格式存储、每个导联的ADC增益和基线是多少。比如100号记录(这是MIT-BIH里最常被用来做示例的一条)的头部文件长这样:
100 2 360 650000 100.dat 212 200 11 1024 995 -22131 0 0 0 101.dat 212 200 11 1024 1027 -22131 0 0 0 # 69 M 1085 1629 x x 0 0 0第一行依次是:记录名、导联数量、采样率(360Hz)、采样点总数650000。第二、三行每一行描述一个导联信号,各字段含义可以整理成下面这个表:
| 字段 | 示例值 | 含义 |
|---|---|---|
| 文件名 | 100.dat | 该导联对应的数据文件 |
| 存储格式 | 212 | 每个样本编码方式 |
| ADC增益 | 200 | 200个ADC单位对应1mV |
| ADC分辨率 | 11 | ADC有效位数是11位 |
| ADC零值 | 1024 | 基线对应的ADC码值 |
| 首个采样值 | 995 | 用于校验读取是否正确 |
| 校验值 | -22131 | 数据文件校验和 |
| 其余字段 | 0 0 0 | 一般用不到,可忽略 |
很多初学者会疑惑:既然ADC分辨率写了11位,为什么存储格式是212?212不是说12位吗?这个问题我后面会展开讲。这里只需先建立起一个概念:.hea文件里每一个数字都不是随便写的,它们共同决定了解析.dat文件时你需要怎么操作。
2.2 数据文件(.dat):212格式的位级解码
.dat文件是二进制数据,MIT-BIH最典型的是212格式。所谓212,是指"两个12位样本打包在3个字节里"。
为什么会有这种别扭的做法?因为早期存储介质和传输带宽都非常有限,工程师们为了使每一比特都不浪费,把两个样本的位交错塞进了三个字节。用现代计算机的视角看,这种格式已经谈不上优雅,但你只要想读MIT-BIH就绕不开它。
具体拆解规则如下,假设连续三个字节为b0、b1、b2:
- 第一个样本 = b0的全部8位 + b1的低4位(作为样本的位8到位11)
- 第二个样本 = b1的高4位(作为样本的位0到位3) + b2的全部8位(作为样本的位4到位11)
如果用Python写一个解析函数,核心逻辑是这样的:
import numpy as np def parse_212(data: bytes) -> np.ndarray: n = (len(data) // 3) * 2 samples = np.zeros(n, dtype=np.int16) for i in range(0, len(data) - 2, 3): b0, b1, b2 = data[i], data[i+1], data[i+2] idx = (i // 3) * 2 samples[idx] = (b0 | ((b1 & 0x0F) << 8)) - 1024 samples[idx+1] = (((b1 >> 4) & 0x0F) | (b2 << 4)) - 1024 return samples这里有个细节值得注意:解析出来的12位无符号值本身并不等于真实的信号幅度,还需要减去ADC零值(基线)。对MIT-BIH来说,ADC零值通常约等于1024。减完之后,得到的才是带有符号的ADC码值,范围大约在-1024到+1023之间。
如果你解析完发现波形整体抬高了1V,或者波形上下超出正常心电的大小的几十倍,多半就是没有做这一步基线减法。
2.3 注释文件(.atr):心拍标注与节律标注存储格式
.atr文件存的是心拍标注和节律标注,它决定了你训练分类器时的标签从哪来。比如R波位置在哪、这个心拍是正常还是室性早搏、这一整段节律是窦性还是房颤。
.atr是二进制格式,结构上比.hea和.dat复杂不少。它包含注释类型字节、时间偏移量、以及可选的附加信息。由于官方WFDB工具包里的rdann函数已经封装好了所有解析逻辑,我强烈建议除非有特殊工程需求(比如做嵌入式端到端系统、不能依赖第三方库),否则直接用官方工具读取就好,不需要自己从头实现。
需要特别注意的是:标注文件里的时间位置通常以"采样点"为单位。如果标注说第1000个采样点是一个正常心拍,对应时间是1000/360 ≈ 2.78秒。在把标注和时间轴拼接可视化的时候,这个单位换算很容易被忽略,一旦搞混,画出来的图就是标签和波形整体错位。
3. 从ADC码值到毫伏:单位换算与标定细节
这部分是我踩坑最深的地方,也是很多信号处理教程里讲得最少的地方。心电数据文件里存储的原始数值,本质上只是ADC转换后的数字码,并不是直接以毫伏为单位的电压值。要把数据变成"可用于分析的心电信号",必须经过一步标定换算。
3.1 增益与基线:为什么不能直接用原始整数
每个导联在采集时都有自己的增益设置。增益的物理意义是"多少个ADC单位对应1mV的信号"。MIT-BIH常见的增益是200,意味着1mV电信号经过放大和ADC量化后,会产生200个单位的码值变化。
如果只知道增益还不够,因为ADC输出还有一个基线偏移,也就是无信号输入时的静态码值。换算公式是:
电压(mV) = (ADC码值 - 基线值) / 增益
举个例子。假设某个采样点的ADC码值是1800,基线是1024,增益是200,那么对应的电压是:
(1800 - 1024) / 200 = 3.88 mV
3.88mV的QRS波峰值虽然偏高,但还在合理范围内。如果不减基线直接用1800除以200,得到9mV——这在心电信号里是绝对不正常的幅度,任何阈值型R波检测器都会疯掉。
3.2 一段真实波形数据的换算过程
我们拿一小段伪码值序列来做完整换算演示,这也是我自己平时验证解析正确性的方法。
假设某个导联连续几个采样点的ADC码值为:
1040, 1052, 1100, 1248, 1420, 1560, 1450, 1280采样率360Hz,增益200,基线1024。换算后的电压值(mV)为:
| 采样点 | ADC码值 | 电压(mV) |
|---|---|---|
| 1 | 1040 | 0.08 |
| 2 | 1052 | 0.14 |
| 3 | 1100 | 0.38 |
| 4 | 1248 | 1.12 |
| 5 | 1420 | 1.98 |
| 6 | 1560 | 2.68 |
| 7 | 1450 | 2.13 |
| 8 | 1280 | 1.28 |
从这串数值能看出,第5、6个采样点正好落在QRS波群的高幅值区域,幅度在2mV上下,符合正常体表心电的典型特征。这说明换算基本是对的。
如果画出来的波形R波高度长期停留在0.1mV以下,或者动辄十几毫伏,排查方向首先应该是标定参数用错了,而不是滤波算法有问题。
3.3 常见换算错误清单
把我和同事们在项目里遇到过的错误归类整理一下,基本都是这几类:
- 忘记减基线。这是最常见的。很多人读完二进制后直接把码值当成信号幅值,导致所有信号整体抬高几百个单位。
- 增益除反了。有的代码写成了
码值 * 增益,结果波形幅度膨胀几十倍,依然没有任何报错。 - 基线值用错。MIT-BIH部分记录并非所有导联基线都是1024,可能略有差异,要用头部文件里每导联对应的ADC零值字段。
- 多导联共用一个换算参数。不同导联的增益可能不同,如果你写死了一个值,另一条导联的波形就会系统性偏差。
这类问题很小,但危害很大。它不会让程序崩溃,不会报异常,只会让分析结果一路错下去。我现在处理任何新数据源时,第一步永远是画波形图,肉眼确认幅度和形态在合理范围内,再做后续处理。
4. 采样率、时间轴与重采样:格式之外的隐性陷阱
数据格式解析对了,单位换算对了,接踵而来的是另一批坑:时间轴重建、重采样、以及跨格式数据对齐。这些问题的本质仍然是"对格式的理解不够深"。
4.1 从头部信息重建正确的时间轴
.hea文件里的采样率是重建时间轴的唯一依据。MIT-BIH的360Hz意味着每个采样点之间的时间间隔是1/360 ≈ 2.78ms。
画图的时候,横轴必须以"秒"为单位的话,就用np.arange(n_samples) / fs;计算RR间期的时候,相邻两个R波相隔的采样点数除以采样率才是间隔时间。如果采样率搞错,所有时间相关特征都会出错。
我见过一个比较典型的错误:把一条360Hz记录当成500Hz来算,结果算出来的心率整体偏高约39%,但波形形态看起来依然正常。如果你不仔细核对,这个问题可以在流程里潜伏很久。
4.2 重采样:不只是"隔几个点抽一个"那么简单
算法研究里,经常需要把不同采样率的数据统一到同一个采样率,比如把360Hz降到128Hz。很多初学者图省事,直接data[::3]抽取,结果高频噪声发生混叠,波形虽然还在,但细节全部失真。
正确的做法是先经过低通抗混叠滤波,再抽取。降采样到128Hz时,先过截止频率大约64Hz的低通滤波器,再按比例抽取。Python里可以用scipy.signal.resample_poly,它会自动处理抗混叠:
from scipy.signal import resample_poly fs_old = 360 fs_new = 128 # up和down是整数,满足 fs_new / fs_old = up / down up, down = 128, 360 resampled = resample_poly(signal_mv, up, down)这里额外的坑是:重采样之后,原来以采样点为单位的心拍标注位置也必须同步换算,否则标注和波形就错位了。我见过好几个项目在波形上做了重采样,忘了处理标签,训练时标签和特征错位,模型收敛慢还找不出原因。
4.3 EDF+间断记录带来的时间不连续挑战
WFDB格式的记录基本是连续采集的,时间轴重建相对简单。但如果你处理的是EDF+格式的动态心电(Holter)数据,情况就不同了。EDF+支持"间断记录"——记录过程中因为某些原因(比如更换电极、设备暂停)停止了一段时间再继续采集。这种情况下,文件里记录的采样点总数和真实时间跨度不是简单的除法关系,必须依靠EDF+里的时间戳信息来重建真实时间轴。
我处理过一批Holter数据,一开始直接用采样点数除以采样率来还原时间轴,结果把几次间断都当成了连续数据,做HRV分析时所有反映副交感神经活性的指标全乱了。后来靠EDF+注解通道里的时间戳一一对齐,才算把数据救回来。对于这种情况,画时间轴之前先检查是否有间断记录,是个好习惯。
5. 常见心电数据格式盘点:WFDB之外,还要知道这些
5.1 EDF/EDF+:医学信号通用格式的优缺点
EDF(European Data Format)是医学领域通用的时间序列存储格式,最早为睡眠脑电设计,但心电、肌电、呼吸等信号都可以存储。它的扩展版EDF+支持注解通道和时间戳,应用更广。
EDF的优势在于头部信息完全公开,结构相对规整:文件开头是一个固定256字节的ASCII头部,包含患者ID、记录ID、开始时间、信号数量等;紧接着是每个信号256字节的通道描述块,定义了物理单位、物理量程、数字量程、采样数等关键信息。
换算逻辑很直接:物理量程最小值对应数字量程最小值,物理量程最大值对应数字量程最大值,中间线性映射。也就是说,给定数字值d,物理值p为:
p = phys_min + (d - dig_min) * (phys_max - phys_min) / (dig_max - dig_min)
但EDF有两个公认的痛点:一是患者信息等字段是定长ASCII,内容超长会被截断;二是不同厂商对EDF的实现并不完全一致,有的厂商会在保留字段里塞私有信息,解析时需要容错处理。
5.2 SCP-ECG与HL7 aECG:面向诊断与传输的标准协议
这两个格式和WFDB、EDF不是一个维度。SCP-ECG和HL7 aECG主要面向医疗设备厂商、心电图机诊断报告以及医疗信息系统之间的数据交换。
SCP-ECG在欧洲使用较广,它不只是波形存储,还包含诊断结论、节律分析、测量值等结构化信息。HL7 aECG则是把12导联心电波形编码成XML结构,便于在HL7消息体系里传输。这两种格式的共同特点是结构复杂,有嵌套关系,解析成本明显高于WFDB和EDF。
如果你只是做算法研究,我不建议在这两种格式上花太多时间。直接找厂商要SDK,或者让厂商导出成EDF或CSV格式,把精力集中在算法本身,性价比更高。但如果你做的是医院信息系统集成项目,那就没法绕开,需要花钱花时间啃规范文档。
5.3 面向机器学习流水线的轻量格式:CSV与JSON
现在大多数心电深度学习项目,其实并不需要直接面对WFDB或EDF。更常见的做法是预先解析成CSV或JSON,一个文件里放下全部导联数据,另一个文件放标注,方便后续DataLoader直接读取。
我在做心电分类项目时,会把MIT-BIH记录解析成统一CSV结构,每一行包含时间戳、导联I电压、导联II电压、当前心拍标签。这样后续做数据增强、切片、可视化都很方便,遇到格式问题也容易定位。
不过CSV的缺点也很明显:体积大、没有压缩。一段10分钟、360Hz、双导联的记录约43万个采样点,CSV文件轻松到几十MB。所以轻量格式适合做模型原型验证和小规模测试,真正的大规模数据管理还是要回到二进制格式或者专用存储方案。
| 格式 | 波形存储 | 标注/事件 | 适用场景 | 解析难度 |
|---|---|---|---|---|
| WFDB/MIT-BIH | 二进制(212等) | 独立.atr | 算法研究、公开数据集 | 中等,需位运算 |
| EDF/EDF+ | 二进制+ASCII头 | 注解通道 | 多信号采集、临床数据 | 较简单 |
| SCP-ECG | 二进制分段 | 内嵌诊断信息 | 心电图机、欧洲医疗系统 | 较复杂 |
| HL7 aECG | XML编码 | 结构化标签 | 医疗信息系统传输 | 较复杂 |
| CSV/JSON | 明文 | 人为约定 | 机器学习、原型验证 | 最简单 |
6. 解析完成后的验证方法:怎么确定自己没有读错
格式解析最麻烦的不是写出解析代码,而是确认解析结果是正确的。很多错误从波形上一眼就能看出不对,但也有的错误很隐蔽,需要系统性验证。
6.1 用官方工具做交叉验证
最直接的验证方法,是同一条记录分别用你的解析代码和官方工具读取,然后逐点对比差异。我通常写这样一个脚本:
import numpy as np from wfdb import rdrecord from my_parser import parse_mit_record rec_std = rdrecord('100', sampto=3000).p_signal rec_mine = parse_mit_record('100', nsamples=3000) max_diff = np.max(np.abs(rec_std - rec_mine)) print(f'max diff: {max_diff}')如果max_diff为0,说明你的解析逻辑和官方实现完全一致。如果不为0,优先检查增益、基线、位运算方向。这个方法尤其适合在有标准工具的前提下确认你自己的独立实现是否可信。
6.2 信号本身的合理性检查
在某些场景下,你拿不到官方工具,或者数据是自采的,没有现成真值做对照。这时候要依靠信号本身的生理合理性来判断:
- 基线是否在0附近。正常心电经过预处理后,基线应该在0附近小幅波动,如果整体明显偏移,说明直流偏置没有去除。
- R波幅度是否在合理范围。常规体表心电的R波幅度在0.5到2.5mV之间。如果解析出来的R波动辄10mV以上,大概率是换算错误。
- 静息心率是否合理。利用简单的R波检测计算平均心率,正常成人在60到100次/分之间。如果算出200次/分,要么心率真的异常,要么时间轴换算错了,要么R波检测把T波误判成R波了。
- 频谱能量分布。心电的主要能量集中在0.5到45Hz之间。如果频谱在50Hz处出现极高的尖峰,多半是工频干扰;如果在100Hz以上仍有大量能量,要怀疑是不是混入采集噪声或重采样算法出了问题。
6.3 踩过这些坑之后的一点经验
做了几年心电数据后,我的体会是:数据格式这类问题,前期投入时间吃透,后面能省下无数返工时间。我曾经为了赶项目进度,随便在网上找了一个解析MIT-BIH数据的脚本就开始跑模型,结果后面发现标签错位,整个实验重做了一遍。那次的教训让我养成了一个习惯:拿到任何新数据,先花半天时间确认格式细节,再用交叉验证脚本确认解析正确,最后一定画一张波形图看看形态。
另外一个容易被忽略的经验是:不同数据集之间的标注体系可能不一样。MIT-BIH的标注有N、V、A、L等类型,EDF+里的注解可能是文本描述,其他数据库可能用完全不同的编码方式。做跨数据集训练或迁移学习时,要先把标注映射关系理清楚,否则模型在训练集上表现正常,一到另一个数据集上就彻底乱了。
还有一个小建议:每个团队可以维护一套自己的数据解析模块,把常用格式(至少WFDB和EDF)都做一次完整实现,并为每个格式编写一个与官方工具对照的自动化测试用例。这套基础建设投入不大,但能避免后面无数个"排查半天结果发现是数据读错"的夜晚。
数据格式这个东西,看起来枯燥,却是整个心电信号处理流程里最不该出错的环节。搞定了它,后面的滤波、特征提取、分类才有讨论的意义。下一篇我准备聊聊心电信号预处理里的滤波器设计边界问题,这又是一个能用简单代码做错、但做对了效果天差地别的方向。