心电信号处理这个系列写到第四篇,前几篇聊了不少滤波、基线漂移校正、R峰检测这些算法层面的东西。今天这篇想换个视角,聊一个看起来最不起眼、实则最容易卡住新手的环节——心电数据格式。如果你从公开数据集下载过MIT-BIH,对着.hea、.dat、.atr这些后缀一脸懵;如果你在自己设备上采了一堆数据,却不知道怎么转成能跑算法的格式;如果你解析波形时发现幅值动辄几千、时间轴完全对不上,那这篇就是写给你的。
我会从数字化原理讲清楚格式为什么长这样,再把WFDB、SCP-ECG、HL7 aECG这些主流格式挨个拆开,最后用Python手把手把MIT-BIH的212格式从裸字节解析成心电波形。不管你是做科研、搞医疗器械,还是自己在捣鼓可穿戴设备,这份笔记都能帮你省下不少弯路。
1. 格式问题为什么值得花一整篇讲:先理解数字化那一刻发生了什么
很多初学者拿到心电数据的第一反应是打开文件直接画图,结果画出来一团乱麻。这不奇怪——你看到的文本、二进制、XML,其实都是模拟心电信号在数字化那一刻被“硬编码”下来的结果。想读懂各种格式,得先回到源头。
1.1 模拟信号变成数字数据,到底变了什么
心电图机或采集设备的电极贴在皮肤上,测量到的是毫伏级别的电位差,这是一个随时间连续变化的模拟信号。要让计算机处理它,必须先做两件事:采样和量化。
采样决定了时间分辨率,常见心电采样率有250Hz、360Hz、500Hz甚至1000Hz。360Hz这个数字有点特殊,它是MIT-BIH数据集的采样率,因为当年磁带记录和数字化设备的限制,后来成了科研界的“默认值”。理论上根据奈奎斯特定理,要保留心电信号里常见的100Hz以内的频率成分,采样率至少得200Hz,360Hz留了足够裕量,还能照顾到QRS波群里的高频细节。
量化决定了幅度分辨率。模拟信号是连续的,数字化后只能用有限个离散等级来表示。心电采集通常用12位、16位或24位的ADC(模数转换器)。12位意味着整个量程被切成了4096个等级,16位则是65536个等级。
举例来说,假如ADC的量程是±5mV,12位精度下每个最小刻度代表约2.44μV,而16位精度下约0.15μV。这就是为什么有些高精度心电设备能用16位甚至24位ADC——为了捕捉微弱的P波细节和ST段变化。
除了采样率和位深,还有两个概念必须搞清楚:增益(gain)和基线(baseline)。
心电信号本身只有毫伏级,ADC的输入量程通常是伏特级或几百毫伏级,所以模拟前端必须先放大信号。这个放大倍数就是增益,单位通常是“ADC码值/mV”,比如200、250、1000。
基线则代表“零电位”在ADC输出中对应的码值,常见的是1024或2048,也就是ADC量程的中点。
这里有个很经典的坑:文件里存的是ADC码值,不是毫伏。如果你不做(raw - baseline) / gain这一步换算,画出来的波形数值就会是几千甚至上万,看起来像噪音而不是心电。后面我会专门演示换算过程。
1.2 设计一种数据格式,本质上是在平衡哪几件事
理解了数字化参数之后,再看各种心电数据格式就容易多了。你会发现,不管格式怎么变,它都要解决同样几个核心问题:
第一,可恢复性。格式里必须保留采样率、位深、增益、基线这些元数据,否则拿到波形数据的人无法还原真实的物理信号。MIT-BIH的.hea头文件专门干这个,SCP-ECG里有专门的“ECG Acquisition”章节,HL7 aECG则用字段标签来记录。
第二,压缩与存储效率。心电数据动辄几小时甚至24小时连续记录,12位精度、360Hz采样率下,一小时单通道就是约1.27MB,多通道加上注释会更庞大。SCP-ECG用了差分编码和专用压缩算法,体积能做到原始数据的几分之一。而MIT-BIH的212格式,本质上也是一种压缩——用12位存储却能把两个通道塞进3个字节,省掉了25%的空间。
第三,注释与事件信息。一份可用的心电记录不能只有波形,还得有R峰位置、心拍类型(正常、室早、房早等)、起搏器事件这些标注。MIT-BIH的.atr注释文件、SCP-ECG的Reference Beat和Beat Rhythm章节,都是为了解决这个问题。这也是心电格式区别于普通波形文件的关键。
第四,可互操作性。医院里的心电数据可能来自不同厂商的设备,要做电子病历共享、远程会诊,格式必须能跨系统流通。DICOM和HL7 aECG就是为了这个目的出现的标准。
明白了这四个维度,再去看各种格式的文档,你会发现自己能快速抓住它设计的核心意图。
2. 主流心电数据格式全景图:从科研机房到临床设备再到可穿戴
心电数据格式不是一个“标准答案”,而是按场景分层的。科研界、医疗设备厂商、可穿戴生态,各有各的惯例。
2.1 WFDB/MIT-BIH:科研界默认的“硬通货”
如果你在心电信号处理领域待过,一定绕不开MIT-BIH心律失常数据库。它由MIT和Beth Israel Hospital在1980年代发布,包含48条半小时双通道心电记录,每条记录都带有专业医生标注的心拍类型。几十年来,它成了算法评测的“考卷”,几乎所有论文都会在MIT-BIH上报告准确率。
MIT-BIH采用的格式统称WFDB(WaveForm DataBase)格式,由三部分组成:
.hea头文件:文本格式,记录采样率、通道数、位深、增益、基线、样本数等信息.dat数据文件:二进制,存储原始波形数据,常见212格式、16位格式、8位格式.atr注释文件:二进制,存储R峰位置和心拍类型标签
WFDB是一套完整工具链的名字,它不仅有数据格式,还包括rdsamp、rdann、wrann等命令行工具和C语言库,后来社区又封装了Python的wfdb库。说它是科研界的事实标准,一点也不夸张。
除了MIT-BIH,很多其他公开数据集也采用或兼容WFDB格式,比如欧洲ST-T数据库、QT数据库等。初学者从WFDB入手,等于同时打开了整个心电科研资源库。
2.2 SCP-ECG与HL7 aECG:医疗互操作的两条路线
如果说WFDB是科学家们“够用就好”的产物,那SCP-ECG和HL7 aECG就是医疗标准化组织的正经作品。
SCP-ECG(Standard Communication Protocol for Computer-Assisted Electrocardiography)是欧洲标准,同时也是ISO 11073-91064。它的设计目标很明确:在医疗系统之间传输心电记录,并且要完整保留诊断信息。SCP-ECG的存储方式是“分章节”的,患者信息、采集参数、参考心拍、节律数据、测量结果各自放在独立章节里,还可以用专用算法压缩波形数据。这种结构在传输效率和信息完整性上做了非常好的平衡。
HL7 aECG则是基于XML的心电标注格式,把波形数据以十六进制字符串嵌在XML里,同时用大量标签描述采样率、导联配置、滤波状态等细节。它的最大优点是纯文本、可读性好、跨平台性强,缺点是文件偏大,适合数据量不算太大的应用场景。
两者对比的话,SCP-ECG更接近“文件存储格式”,HL7 aECG更接近“消息交换格式”。临床心电网络建设里,用SCP-ECG存档案、用HL7 aECG做接口传输,是常见的组合。
2.3 EDF、CSV和厂商私有格式:工程落地里的“现实选择”
EDF(European Data Format)原本是为多导睡眠监测设计的通用生物信号格式,好处是头文件统一、支持多采样率,很多脑电、肌电、心电混合采集设备都支持它。如果你做的是多模态生理信号采集,EDF经常是首选。
CSV则是工程里最常见的“逃生通道”。自己拿MCU采集心电、或者读取某款手环导出的数据,最通用、最省事的方式就是存成CSV,每列一个导联、每行一个采样点,再加一列时间戳。它的缺点是文件大、没有标准元数据规范、注释信息难附加,但它够简单、够通用。很多算法原型开发阶段,我都会先把数据转成CSV或NPY来调试,跑通逻辑后再回到正式格式做工程化。
厂商私有格式则是绕不开的现实。Philips、GE、Mortara这些心电图机厂商都有自家的存储格式,用于保存原始波形和诊断结论。这类格式通常不开放完整文档,需要通过厂商SDK或中间软件导出标准格式。这也是很多医院数据科研化利用的第一道门槛。
做个小结,几种格式的定位差异很大:
| 格式 | 典型场景 | 优点 | 缺点 |
|---|---|---|---|
| WFDB/MIT-BIH | 科研算法研发、公开数据集 | 生态成熟、配套工具齐全 | 医疗设备原生支持较少 |
| SCP-ECG | 欧洲医疗心电网络存储 | 标准化程度高、支持压缩和诊断信息 | 解析复杂度较高 |
| HL7 aECG | 医疗系统间数据交换 | XML可读性好、跨平台 | 体积大、不适合原始波形存档 |
| EDF | 多导生理信号采集 | 通用性强、支持多路信号 | 心电专用功能弱 |
| CSV/自定义 | 嵌入式设备、算法原型 | 简单直观、开发快 | 缺乏标准、元数据易丢 |
3. 手把手解析MIT-BIH数据:从裸字节到心电波形
了解了全景之后,我们选一个最常见的场景做深度实操:解析MIT-BIH数据库。我以经典记录100为例,从.hea头文件一路解析到位,把212格式的.dat数据变成能画图的心电波形。
3.1 读头文件:.hea里每一个字段都不是摆设
打开100.hea,内容长这样:
100.dat 2 212 360 650000 0 11 0 0 0 0 0 100.dat 200 11 1024 995 - 0 0 0 0 0第一行是全局信息,逐字段含义如下:
100.dat:数据文件名2:通道数量212:数据存储格式编号360:采样率,单位Hz650000:每条通道的样本总数0:信号计数基线(该记录为0)11:每个样本的位深(实际使用12位中的11位)- 后续的
0和某个特殊值代表分组信息,一般用默认值
第二、三行是每个通道的详细信息:
100.dat:该通道数据所在文件(双通道共用同一个文件)200:增益,单位是ADC码值/mV。这里表示每1mV心电信号对应200个ADC码值11:该通道位深,同样是11位1024:基线值,对应0mV电位995:信号第一个采样点的ADC码值(这个数值本身没多大含义,只是记录初始状态)-:无符号修正标记;若为+表示需要对码值做额外的符号扩展- 后面的字段按需使用
实际操作中,直接用wfdb.rdsamp()读取最省事:
import wfdb signals, fields = wfdb.rdsamp('100', sampto=3600) print(fields['fs']) # 采样率:360 print(fields['sig_name']) # ['MLII', 'V5'] print(fields['adcgain']) # [200., 200.] print(fields['baseline']) # [1024, 1024] print(fields['units']) # ['mV', 'mV'] print(signals[:5])signals已经是换算成mV的物理量,fields把元数据都装好了。但如果你想理解底层原理、或者未来需要解析非标准记录,我建议还是手动读一次头文件,搞清楚信息流是怎么走的。
3.2 解析212格式:每3个字节装下2个样本
212格式是MIT-BIH中最常用的格式,也是初学者最容易读错的地方。它的核心特点是:每个样本用12位(bit)存储,但为了节省空间,两个通道的12位数据被交叉打包在3个字节(24位)里。
拆开看是这样的逻辑:每3个字节组成一组,这一组里承载两个采样点——第一个点使用第1个字节的全部8位,加上第2个字节的低4位;第二个点使用第3个字节的全部8位,加上第2个字节的高4位。
用Python手动解析的代码如下:
import numpy as np def read_212(dat_path, nsamp_per_channel): raw = np.fromfile(dat_path, dtype=np.uint8) total_samples = nsamp_per_channel * 2 n_bytes_needed = (total_samples * 12) // 8 raw = raw[:n_bytes_needed].reshape(-1, 3) # 第一个通道:第0字节 + 第1字节的低4位作为高4位 ch1 = raw[:, 0].astype(np.int16) | ((raw[:, 1] & 0x0F).astype(np.int16) << 8) # 第二个通道:第2字节 + 第1字节的高4位作为高4位 ch2 = raw[:, 2].astype(np.int16) | ((raw[:, 1] >> 4).astype(np.int16) << 8) # 11位有符号数,超出2048表示负数,做符号扩展 ch1[ch1 >= 2048] -= 4096 ch2[ch2 >= 2048] -= 4096 return ch1, ch2 ch1_adc, ch2_adc = read_212('100.dat', 650000) # 物理单位换算 ch1_mv = (ch1_adc - 1024) / 200.0 ch2_mv = (ch2_adc - 1024) / 200.0这段代码里有几个细节值得说明:
每个组里第1个字节和第3个字节存的都是12位值的低8位,而中间那个字节被拆成了两半,低4位给第一通道、高4位给第二通道。网上有些解析代码把中间字节高低4位的分配弄反了,画出来的两个通道互相串扰,QRS波会同时出现在两个导联上,排查起来非常痛苦。
符号扩展也是个关键点。212格式实际用的是11位有符号数,范围是-1024到1023。如果不做>= 2048的判断,负的采样值会被读成几千的正整数,波形看起来就像满屏的尖峰。
换算成物理单位后,ch1_mv的范围应该在±2mV左右,画出来能清晰看到P波、QRS波群和T波,这才是正常的。
3.3 注释文件.atr:心拍位置与类型标签
只有波形没有标注,MIT-BIH的价值会大打折扣。.atr文件用紧凑的二进制格式存储注释,每个注释对应一个采样点位置和一个类型码。
用wfdb库读取注释非常简单:
ann = wfdb.rdann('100', 'atr') ann_sample = ann.sample[:10] # 前10个注释的采样点索引 ann_symbol = ann.symbol[:10] # 对应的心拍类型 print(ann_sample) print(ann_symbol)常见的心拍类型符号包括:
N:正常窦性心拍L:左束支传导阻滞R:右束支传导阻滞A:房性早搏V:室性早搏Q:未分类心拍
R峰标注位置的意义在于,它可以作为算法评估的“金标准”。你训练QRS检测网络,输出结果和ann_sample对比,就能算出灵敏度、阳性预测率这些指标。做心拍分类时,ann_symbol就是标签来源。
如果想自己解析.atr,底层逻辑是每两个字节为一组:第一个字节的高4位表示注释类型标志,低4位和第二个字节组合成时间增量。解释这个格式确实需要读MIT-BIH的注解文档,我的建议是——除非你有特殊需求,否则直接复用wfdb.rdann就好,这部分的轮子已经足够成熟。
3.4 实际跑一遍:用Python画出带标注的心电波形
把前面的内容串起来,用wfdb一行指令加载数据和标注,再用matplotlib画出第一个通道前5秒的波形和R峰位置:
import wfdb import matplotlib.pyplot as plt import numpy as np record = wfdb.rdrecord('100', sampto=5*360) ann = wfdb.rdann('100', 'atr', sampto=5*360) t = np.arange(len(record.p_signal)) / record.fs sig = record.p_signal[:, 0] plt.figure(figsize=(12, 4)) plt.plot(t, sig, linewidth=0.8) for s in ann.sample: if s < len(record.p_signal): plt.axvline(x=s/record.fs, color='red', linestyle='--', linewidth=0.6) plt.xlabel('Time (s)') plt.ylabel('ECG (mV)') plt.title('MIT-BIH Record 100, Channel 1 + R peaks') plt.tight_layout() plt.show()实际运行这段代码,你会看到清晰的、稳定的QRS波群,R峰位置对齐在每个波峰上。如果你看到波形幅值在0附近波动、R峰标注整体偏移,那就要往前排查——大概率是采样率或增益换算出了问题。
4. 格式转换、工具链与真实项目中的选型取舍
读数据和可视化只是第一步。真正做算法研发或产品落地时,你会频繁遇到格式转换和工具选型的问题。
4.1 值得记住的工具和库
Python生态里,最核心的是wfdb(官方维护的WFDB Python接口)。它能读几乎所有WFDB格式的记录和注释,也能写数据生成自己的数据集。配合numpy和matplotlib,常规科研流程完全够用。
heartpy是一个更偏应用的心电分析库,内置了滤波、心率计算、HRV(心率变异性)分析等功能,适合快速出指标。但要注意,它背后的核心其实是读入标准格式后的算法流程,数据加载模块的灵活性不如wfdb。
如果你是处理自己采集的数据,pyEDFlib用来写EDF文件,scipy.io或pandas用来处理CSV,再加上wfdb.wrsamp把CSV转成WFDB格式,一套流程就闭环了:
import pandas as pd import wfdb df = pd.read_csv('my_ecg.csv', header=0) samples = df[['ch1_mv', 'ch2_mv']].values n = samples.shape[0] wfdb.wrsamp( record_name='my_record', fs=500, units=['mV', 'mV'], sig_name=['I', 'II'], p_signal=samples, base_time='12:00:00', base_date='2023-01-01' )这样一来,你自己采集的数据就能用wfdb库、甚至用各种临床算法直接处理了。
4.2 三种典型场景的格式选型建议
做算法研究:直接用WFDB/MIT-BIH格式,保持和公开数据集一致。减少格式转换的坑,评测指标才可信。
做医疗产品对接:优先支持SCP-ECG和HL7 aECG。虽然解析麻烦,但医院系统的数据交换就吃这套标准。建议把解析层封装成独立服务,用协议缓冲区或数据库表来中转,避免核心业务被格式细节拖累。
做可穿戴设备或IoT:不建议在端侧用复杂格式。端侧记录用轻量二进制+元数据头,或者直接用CSV片段,云端再统一转成标准格式做分析。压缩算法优先参考SCP-ECG的差分编码思路,它把“相邻采样点差值往往很小”这个信号特性利用得非常充分。
5. 常见问题与排查技巧实录
最后分享几个我在实际项目中踩过的坑,这些场景很多是文档里查不到的,但几乎每隔几个月就会有人问一次。
5.1 波形幅值几千甚至上万
如果你画出来的波形数值在几千级别,几乎可以断定没有做增益和基线换算。记住标准公式:mv = (adc - baseline) / gain。使用wfdb.rdsamp()时返回的已经是物理单位,但如果你自己解析.dat,这个换算必须自己做一遍。另外注意基线和增益是“每通道一个值”,别只用一个通道的参数去换算所有通道。
5.2 时间轴和采样率对不上
心电记录每条通道的采样率可能不同,读取时要用各自的fs。MIT-BIH这类标准数据通常两个通道采样率一致,但EDF文件里多信号不同采样率很常见。统一处理时,要么重采样到同一频率,要么按时间戳对齐,不能想当然认为同一行索引代表同一时间。
5.3 两个通道波形完全一样或互相串扰
这种情况九成是212格式字节拆包时高低位分配写反了。第1个通道用第2个字节的低4位,第2个通道用第2个字节的高4位——反了就串道。排查方法很简单:分别把两个通道画出来,观察QRS主波的极性是否一致。如果两个通道几乎一模一样,优先检查字节拆包逻辑。
5.4 注释文件读取后R峰位置整体偏移
R峰标注偏移通常是采样率传递错误导致的。比如解析器默认采样率是250Hz,但实际是360Hz,那么所有标注的时间索引都会系统性地偏移。用ann.sample与波形索引对比时,一定要用同一个采样率口径。
做一个速查表收着,遇到问题查一查:
| 现象 | 常见原因 | 检查方向 |
|---|---|---|
| 幅值为几千级别 | 未减基线、未除增益 | 检查baseline和adcgain |
| 时间轴错位 | 采样率口径不一致 | 确认文件标注的fs |
| 双通道波形相同 | 212格式高低位拆包错误 | 重查字节分配逻辑 |
| 注释与波形对不上 | 采样率或样本数不一致 | 对齐fs和nsamp |
| 数据量大、读取慢 | 格式未压缩 | 考虑转SCP-ECG或差分压缩格式 |
| 跨系统传输乱码 | 格式不受支持 | 转EDF或HL7 aECG |
5.5 转换过程丢信息
wfdb2edf这类工具能完成格式转换,但转换途中有些隐性损耗你要清楚:SCP-ECG里的诊断结论、起搏器标记、某些厂商的专有测量参数,在转换后可能丢失。转换前最好列一个字段清单,确认哪些信息必须保留,必要时自己写转换代码而不是依赖通用工具。
根据我个人经验,处理心电数据格式最重要的是养成“先读元数据再读波形”的习惯。拿到任何一份数据,第一件事不是画图,而是打开头文件或看fields字典,确认采样率、通道数、位深、增益、基线五个核心参数。这五个性价比极高的字段能帮你规避掉绝大多数解析陷阱,剩下的问题,基本都能靠搜索和读官方文档解决。
最后再分享一个实用技巧:如果你经常跟MIT-BIH打交道,建议把几个常用记录的头文件打印出来贴在工位边上。100、105、109、118这些记录,它们的通道配置、增益值、注释分布其实各不相同。多熟悉几份典型的头文件,你对“格式”这件事的理解会比看十篇文档都来得快。