3、数据格式与协议:MIT-BIH格式、WFDB工具包、自定义数据格式设计

聊到心电数据,绕不开一个经典——MIT-BIH格式。说实话,这玩意儿在圈子里就跟“普通话”一样普及。我最早接触它的时候,差点被它的文件结构搞晕。今天咱们就把它掰开揉碎,讲清楚。

3.1 MIT-BIH格式:心电界的“通用语言”

MIT-BIH格式诞生于上世纪80年代,由麻省理工和贝斯以色列医院联合推出。别看它老,至今仍是心电算法验证的黄金标准。为什么?因为它的标注数据太全了,每个心跳类型都标得清清楚楚。

一个完整的MIT-BIH记录包含三个文件:

文件后缀 文件名示例 存储内容
.dat 100.dat 原始心电信号数据(二进制)
.hea 100.hea 头文件,描述信号参数
.atr 100.atr 注释文件,记录心跳类型和位置

我刚开始做心电分析时,犯过一个低级错误——只拿了.dat文件就跑,结果解析出来的数据全是乱码。后来才明白,.hea文件才是“钥匙”。它里面记录了采样率、增益、ADC分辨率这些关键参数。

举个例子,一个典型的.hea文件长这样:

100 2 360 650000
100.dat 212 200 11 1024 995 -22131 0 MLII
100.dat 212 200 11 1024 995 22131 0 V5
# 69 M 1085 1629 x1
# 记录开始时间:1984-01-01 00:00:00

我来解释一下:第一行表示记录名、导联数、采样率(360Hz)、信号点数。第二行和第三行分别描述两个导联的信号格式。212代表一种特定的二进制编码方式,200是增益(单位是ADC单位/mV),11是ADC位数。

我的小技巧: 解析212格式时,记得每个采样点占12位,两个采样点打包成3个字节。我第一次写解析代码时,按16位去读,结果数据全对不上。后来查了三天资料才找到原因。

3.2 WFDB工具包:别自己造轮子

既然MIT-BIH格式这么复杂,难道每次都要手写解析器?当然不用。WFDB(WaveForm DataBase)工具包就是专门干这个的。它由MIT开发,支持C、Python、MATLAB等多种语言。

我个人习惯用Python版本的wfdb库,安装就一行命令:

pip install wfdb

读取MIT-BIH数据,核心代码其实就几行:

import wfdb

# 读取记录
record = wfdb.rdrecord('100', sampto=3600)  # 只读前10秒
annotation = wfdb.rdann('100', 'atr', sampto=3600)

# 获取信号数据
signal = record.p_signal  # 二维数组,每列是一个导联
fs = record.fs  # 采样率

# 获取心跳标注
sample_indices = annotation.sample  # 心跳位置
symbols = annotation.symbol  # 心跳类型(N、V、A等)

你看,就这么简单。rdrecord读信号,rdann读标注。sampto参数可以控制读取长度,避免一次加载整个文件——这在处理长程记录时特别有用。

注意: 有些公开数据集的文件名不带后缀,比如“100”而不是“100.dat”。wfdb会自动补全,但前提是三个文件(.dat、.hea、.atr)都在同一个目录下。我曾经因为少复制了一个.atr文件,折腾了半天才发现是标注文件缺失。

WFDB还提供了很多实用功能。比如绘制心电波形:

wfdb.plot_wfdb(record=record, annotation=annotation, 
               time_units='seconds', figsize=(12, 4))

嗯,这里要注意,plot_wfdb默认会显示所有导联。如果你只想看某个导联,可以手动提取信号后用matplotlib画。

3.3 自定义数据格式:什么时候需要自己设计?

MIT-BIH格式虽好,但有些场景下它并不适用。我遇到过几个典型情况:

  • 存储空间受限: MIT-BIH用16位整数存储,对于嵌入式设备来说,12位甚至8位就够了
  • 需要附加信息: 比如患者ID、采集时间、设备状态等,MIT-BIH的头文件扩展性有限
  • 实时流式传输: MIT-BIH是文件级别的格式,不适合逐包传输

我自己设计过一套轻量级格式,结构很简单:

# 自定义心电数据格式(二进制)
# 文件结构:
# [文件头] [数据块1] [数据块2] ...

# 文件头(固定64字节):
# 4字节:魔数(0xECG1)
# 4字节:版本号
# 2字节:采样率(Hz)
# 1字节:导联数
# 1字节:ADC位数
# 2字节:增益(ADC单位/mV)
# 8字节:记录开始时间(Unix时间戳)
# 42字节:保留字段

# 数据块(可变长度):
# 4字节:数据块长度(字节数)
# 2字节:数据块类型(0=原始信号,1=标注,2=事件)
# N字节:数据内容

为什么这么设计?说白了,就是为了解析效率。固定长度的文件头可以一次读取,数据块的分块设计支持随机访问——你想跳转到第10分钟的数据,直接计算偏移量就行,不用从头读到尾。

核心原则: 自定义格式的设计要遵循“够用就好”的原则。不要为了炫技加入太多特性,否则解析代码会变得臃肿。我见过有人把JSON直接塞进二进制文件里,结果解析速度慢了三倍。

3.4 格式转换:打通数据孤岛

实际项目中,你往往需要在不同格式之间来回切换。比如从MIT-BIH转成自定义格式,或者从自定义格式转成WFDB可读的格式。

我写过一个转换工具,核心逻辑是这样的:

def mitbih_to_custom(input_path, output_path, sampto=None):
    # 1. 读取MIT-BIH数据
    record = wfdb.rdrecord(input_path, sampto=sampto)
    ann = wfdb.rdann(input_path, 'atr', sampto=sampto)
    
    # 2. 提取关键信息
    signal = record.p_signal
    fs = record.fs
    gain = record.adc_gain[0]
    bits = record.adc_res[0]
    
    # 3. 写入自定义格式
    with open(output_path, 'wb') as f:
        # 写文件头
        header = struct.pack('<4sIHBBHq42s', 
                            b'ECG1', 1, fs, 
                            record.n_sig, bits, 
                            int(gain), 0, b'')
        f.write(header)
        
        # 写数据块
        for i in range(0, len(signal), 360):  # 每秒一个块
            chunk = signal[i:i+360].tobytes()
            block_header = struct.pack('<IH', len(chunk), 0)
            f.write(block_header)
            f.write(chunk)

这段代码里有个细节:我按每秒一个数据块来组织数据。为什么是每秒?因为360Hz采样率下,每秒正好360个点,方便后续按秒检索。你想想看,如果按任意长度分块,查找特定时间点的数据就得遍历所有块,效率就低了。

避坑指南: 我曾经在格式转换时忽略了字节序问题。MIT-BIH用的是大端序,而我的嵌入式平台是小端序。结果数据读出来全是反的。后来在文件头里加了一个字节序标志位,才彻底解决这个问题。

3.5 实战建议:选型决策

讲了这么多,到底该用哪种格式?我根据经验总结了一个决策表:

场景 推荐格式 理由
算法验证、学术研究 MIT-BIH + WFDB 生态完善,有大量公开数据集
嵌入式设备采集 自定义轻量格式 存储小、解析快、可扩展
云端存储与分析 WFDB或Parquet WFDB兼容性好,Parquet适合大数据分析
实时流式传输 自定义协议(如基于Protobuf) 支持增量传输,带宽利用率高

最后说一句:不管你选哪种格式,一定要写清楚文档。我见过太多项目,格式设计得挺漂亮,但半年后连设计者自己都忘了字段含义。嗯,这可不是段子,是真事。