简介:面向需要在MATLAB中读取与分析EDF生物医学信号的研究与工程人员,可用于睡眠分期、癫痫检测等神经电生理数据预处理,解决从EDF/EDF+文件中高效提取多通道信号的常见需求。压缩包体积仅6KB,共3个M文件,分别承担EDF文件的打开解析、信号数据读取与安全关闭三个环节,覆盖完整读取流程,尤其适合不熟悉底层二进制结构的用户快速上手。借助这些脚本,用户可以免去字节对齐、整型到浮点转换和量程标定的手写调试,并可直接衔接MATLAB信号处理工具箱,实现频谱分析、峰值检测或信号质量评估等后续操作,大幅提升数据准备阶段的效率。当前已有554人浏览学习,适合临床研究人员、脑机接口开发者和生物医学工程学生作为基础工具集成到自己的分析管线中,或用于教学演示与二次开发。
1. 从数据格式到工具链:先分清你要读的是哪种 EDF 文件
EDF(European Data Format)这个后缀在工程领域指代着两类完全不同的东西,一类是医学信号存储标准,另一类是 FPGA 网表描述文件,两者都叫 .edf,但内部结构毫无关联。先说结论:如果是为了读取生理信号数据,主战场在 Python 的 pyedflib 和 MATLAB 2020a 之后内置的 edfread 函数;如果是拿到 Vivado 生成的网表文件,要处理的则是 EDIF 格式,zcu208 移植过程中常见的written.edf其实是电子设计交换格式的产物。这个区别搞不清楚,后面所有操作都会跑偏。
本文标题里的edfread指向的正是第一种场景——把 EDF/EDF+ 格式的医学信号文件读进内存,提取出信号名、采样率、缩放因子和物理单位。这看起来是最基础的一步,但实际项目中 80% 的坑都藏在头文件解析和缩放参数上。下面从文件格式的底层结构讲起,分别覆盖医学 EDF 读取和 FPGA 网表场景,最后给出一套可以直接落地的读取和校验方案。
2. EDF 与 EDF+ 的文件结构:头文件里的 256 字节决定了你能读对多少
2.1 为什么头文件解析会把人绕晕
EDF 标准定义于 1992 年,EDF+ 在 2002 年扩展了注释和时间戳支持。两种格式的物理结构都遵循同一套规则:文件头部固定占据 256 字节的 ASCII 文本,随后是每个信号各占 256 字节的信号头,再往后才是交错存储的数据记录。头部字段全部是固定长度字符串,没有分隔符,靠字节偏移来切分。
edfread这类工具本质上就是做一个字节级解析器,但真正的坑在于各字段的编码方式并不统一。记录时长(duration)字段在不同厂商设备里可能写成1、1.0或者1.000,采样率字段有人按整数写,有人按浮点数写。严格按规范解析当然能正确处理,但实际遇到的文件往往需要做容错处理。简单说,EDF 的读取本质上是一场字段标准化的工作,而不是简单的fread流式读取。
2.2 医学 EDF 场景下 edfread 的选型决策
医学信号读取的可用方案有两条主流路线,各有一套取舍逻辑:
| 场景 | 首选方案 | 备选方案 | 选型理由 |
|---|---|---|---|
| Python + 深度学习链路 | pyedflib | MNE内置的read_raw_edf | MNE 重,pyedflib 轻 |
| MATLAB 信号处理 | 内置edfread(2020a+) | File Exchange 的edfread(旧版) | 内置版输出 table,旧版输出 struct |
MATLAB 用户要特别注意版本差异。2020a 之后内置的edfread会返回一个 table 类型,信号数据放在table.data里,而旧版脚本里的signal/header字段就对不上了。如果你在维护老代码,最常见的报错就是Unrecognized field name "signals",这通常不是路径问题,而是函数行为升级了。
对于 Python 用户,我一般直接用 pyedflib,因为它能明确区分 EDF 和 EDF+,还自带注解通道解析。安装这一步在 Windows 和 Linux 下都没有编译负担,因为项目发布时就已经带了预编译轮子。虚拟环境建好后,一条命令装完即可开工,不需要额外配置编译器路径。
pip install pyedflib2.3 头文件字段映射:手工读取 EDF 时的要点
如果因为某些原因需要在没有现成工具的环境下手动解析,核心逻辑是先按 256 字节读入 ASCII 编码,再按偏移量切片。这里给出 EDF 标准头部的关键字段表。直接可用的规则如下:
| 偏移 | 长度 | 字段含义 |
|---|---|---|
| 0 | 8 | 版本号 |
| 8 | 80 | 患者识别码 |
| 88 | 80 | 记录编号 |
| 168 | 8 | 起始日期 |
| 176 | 8 | 起始时间 |
| 184 | 8 | 头文件总字节数 |
| 192 | 8 | 保留字段 |
| 200 | 8 | 数据记录段数量 |
| 208 | 8 | 每段时长(秒) |
| 216 | 4 | 信号通道数量 |
| 220 | 剩余 | 各信号定义块 |
解析时有个细节:头文件总字节数这个值 = 256 + 信号通道数量 × 256。如果读到的文件头和这个公式对不上,大概率是文件在传输过程中被改动过,或者某些国产设备往保留字段里塞了额外内容。遇到这种情况,ad-hoc 的做法是直接按256 × (1 + 通道数)跳转指针到数据区,绕过异常头信息。
2.4 数据记录的布局逻辑:交织存储与缩放运算
明确了头结构后,数据区的布局是:文件按固定时长的数据记录段(epoch)顺序排列,每一段内再按通道顺序存放该段的所有样本点。也就是说,EDF 不是按「通道-整段数据」排列,而是按「记录段-通道数据块」交织排列。一个最简单的比喻是:你读到的不是「第一通道全部数据、第二通道全部数据」,而是「第 1 秒所有通道、第 2 秒所有通道」的嵌套结构。
读取时还要做一组最关键的换算:原始整数到物理值。EDF 头部每个信号带四个关键参数——物理最小值/最大值、数字最小值/最大值,计算公式如下:
[ physical_value = digital_value \times (phys_max - phys_min) / (dig_max - dig_min) + phys_min ]
这个公式没有写在文件名里,也不像 CSV 那样直接存好算好的值,而是藏在 256 字节的信号头中。经常有人读完数据拿到的还是原始数字,然后画图时发现波形幅度完全不对,多半是漏了把digital转成physical。pyedflib的readSignal会自动换算,但如果你自己写解析器,这一步绝不能省。
3. 用 edfread 在本地跑通医学 EDF 文件的最小操作集
3.1 Python 路径:pyedflib 读取 EDF+/EDF 的标准流程
先看一个可以直接复用的最小脚本:从读取到可视化的完整链路,使用本地文件并核对信号信息。
import pyedflib import numpy as np f = pyedflib.EdfReader("demo.edf") n = f.signals_in_file print(f"通道数: {n}") signal_labels = f.getSignalLabels() print(f"信号标签: {signal_labels}") # 读取第一个通道的完整数据,自动完成物理量换算 data = f.readSignal(0) sfreq = f.getSampleFrequency(0) print(f"采样率: {sfreq} Hz, 数据点数: {len(data)}") # 获取该通道的物理单位 unit = f.getPhysicalDimension(0) print(f"物理单位: {unit}") f.close()这段代码里值得展开的是readSignal的返回值类型。pyedflib返回的是numpy.ndarray,且已经做过数字量到物理量的映射,所以如果你拿到数据后和原始设备导出的数值不一致,不要慌,对比之前先把双方的单位统一。另外getSampleFrequency的精度问题——EDF 头文件里采样率是按 ASCII 字符串存的,浮点转换时如果设备写着256,显示为256;如果写着256.000,某些版本会解析出256.00000000000006之类的近似值,直接用int()强转会出 bug。稳妥做法是先 round 再判断:
sfreq = f.getSampleFrequency(0) if abs(sfreq - round(sfreq)) < 1e-6: sfreq = int(round(sfreq))3.2 EDF 批处理:循环读取时的性能陷阱
真实项目中很少只处理一个文件,批量读取时最常遇到的问题有两个:句柄泄漏和重复解析头部造成的时间浪费。
pyedflib的EdfReader底层是 C 实现的,close()不只是释放 Python 层面的引用,还负责关闭底层的文件描述符。批量处理时如果忘了调用close(),在 Windows 上会直接遇到「文件被占用」错误,在 Linux 上则会在几百个文件之后把文件描述符耗尽报OSError: [Errno 24] Too many open files。下面是推荐的批处理模式:
from pathlib import Path import pyedflib import numpy as np def process_edf_batch(folder): files = list(Path(folder).glob("*.[eE][dD][fF]")) results = {} for fp in files: try: f = pyedflib.EdfReader(str(fp)) # 只取前60秒数据做指标计算 sfreq = f.getSampleFrequency(0) n_samples = int(sfreq * 60) data = f.readSignal(0, 0, n_samples) results[fp.name] = { "mean": np.mean(data), "std": np.std(data), } except Exception as e: print(f"处理失败 {fp.name}: {e}") finally: try: f.close() except Exception: pass return results注意这里的readSignal(0, 0, n_samples)是分段读取技巧,第三个参数是采样点数而不是秒数,可以避免一次性申请超大内存数组。
3.3 MATLAB 内置 edfread 的差异点:table 输出带来的代码变化
MATLAB 场景下直接调edfread是最省力的路线,但版本差异是主要风险来源。以 R2023a 为例,以下代码可以展示当前内置版的完整用法:
% 读取 EDF+ 文件 edfData = edfread('polysomnography.edf'); % edfData 是 table,查看变量名 disp(edfData.Properties.VariableNames); % 提取第一列信号数据 firstSignal = edfData{:, 1}; % 获取信号元数据(部分版本用 thisIsEDFplus 字段标记 EDF+) if isprop(edfData.Properties, 'signalLabels') labels = edfData.Properties.signalLabels; else labels = edfData.Properties.VariableNames; end区别点在于,旧版 File Exchange 的edfread返回[hdr, record],新版返回单个table。如果你的团队里新旧代码混用,建议在代码里加一个运行时检查:用isa(t, 'table')判断走哪一套解析逻辑,而不是直接改所有调用方的代码。
3.4 读取失败时的病症:常见报错排错对照
EDF 读取报错最讨厌的点在于错误信息往往不指向真实原因。这里列一份基于实践经验的排错对照表:
| 报错信息 | 真实原因 | 处理动作 |
|---|---|---|
Invalid file header | 文件不是 EDF 而是 EDF+ 的变体 | 检查文件头前 8 字节是否为0(版本号) |
IndexError: index 0 is out of bounds | 头部通道数解析为 0 | 手动检查 216 偏移处的值 |
Sample frequency must be positive | 采样率字段在文件中为空 | 按设备标准手动补写采样率 |
| 数据全为 0 | EDF+ 注解通道被当成正常信号通道 | 用getSignalLabels()排除EDF Annotations |
| 数据量只有预期的一半 | 信号解析时用错了字节序 | EDF 固定为小端序,numpy默认uint16 |
4. FPGA 场景下的 .edf 文件:zcu208 移植与 Vivado 网表的关系
4.1 两种 .edf 在技术栈上的对应关系
有大量检索行为指向zcu208 edf 移植和vivado 生成网表 edf,这说明很多工程师在实际工作里遇到的 .edf 文件不是医学信号,而是 Xilinx Vivado 生成的 EDIF 网表。这两个领域的技术栈完全没有重叠,但要放在同一篇文章里,因为文件名后缀会使搜索引擎给出混杂结果,读者需要首先能区分自己手里的文件属于哪一类。
判定方法很简单:用文本编辑器打开文件。医学 EDF 文件开头是0和患者信息等 ASCII 文本;Vivado 生成的 EDIF 网表开头是(edif或(Ebsf等括号嵌套的文本。EDIF 是 Electronic Design Interchange Format 的缩写,是 EDA 工具间传输网表的标准格式。
4.2 Vivado 生成 .edf 网表的命令与步骤
在 Vivado 里生成 .edf 网表有两种常见做法:一种是通过 GUI 的 Synthesis Settings 勾选flatten_hierarchy,另一种是用write_edif命令。后者更适合批处理和脚本化流程,也是 zcu208 这类 Versal 平台移植时的标准做法。
# Vivado TCL 命令窗口执行 synth_design -top top_module -part xczu28dr-ffvg1517-2-e \ -mode out_of_context # 生成 EDIF 网表文件 write_edif -force output/zcu208_design.edf这里-mode out_of_context是必须的,它告诉综合器不要进行 IO 插入(因为顶层端口会由更上层的模块或物理约束来决定位置)。如果不加这个参数,生成的网表会额外添加 IBUF/OBUF 缓冲器实例,移植到别的平台时这些缓冲器和原平台绑定,排查起来非常费劲。
生成后的.edf文件是文本格式的网表描述,里面记录了所有逻辑单元、引脚连接和属性。zcu208 移植时注意一点:EDIF 网表不包含布局布线信息,只包含门级网表,所以拿到手后必须重新跑place_design和route_design,不能指望直接生成比特流。
4.3 Xilinx 工具链读取 EDIF 文件的回读验证
EDIF 文件生成后,如何验证它和预期设计一致?Xilinx 提供了read_edif命令配合逻辑综合后的 DCP 文件做形式验证。在 Vivado 的一个新工程里执行:
# 创建内存中的工程设计 read_edif output/zcu208_design.edf link_design -part xczu28dr-ffvg1517-2-e # 检查设计占用的资源 report_utilization -file utilization_report.txt如果link_design阶段报ERROR: Cannot find cell type之类的错误,说明 EDIF 文件引用了 Vivado 当前版本不支持的单元类型,原因通常是综合时使用的器件型号和目标器件型号不一致。
4.4 zcu208 移植 EDF 网表的时序约束坑
zcu208 移植场景还有一个容易被忽略的点:生成 EDIF 时使用的是原设计的时序约束,但移植到新板卡后物理位置、时钟频率都可能变化。write_edif时默认不会把 XDC 约束打包进 .edf 文件,需要在移植后重新添加约束。
建议的工作流如下:
- 原工程中先
synth_design -mode out_of_context - 生成的 .edf 文件和新平台的 XDC 一并落到新工程
- 重新
read_edif+link_design - 执行时序检查
report_timing_summary
整个流程下来,EDF 后缀的网表文件在 zcu208 平台上的移植路径才能闭环。
5. 时间戳解析与 EDF+ 注解通道处理:容易读错的数据隐藏层
5.1 EDF+ 注解通道的结构与读取方式
EDF+ 相比 EDF 的最大变化是引入了独立的注解通道。注解通道的数据保存在EDF Annotations信号中,里面不是波形数据,而是以 TAL(Time-stamped Annotation List)格式存储的事件信息:时间戳、事件文本、持续时间。
用pyedflib读取注解的直接方式是readAnnotations:
import pyedflib f = pyedflib.EdfReader("sleep_edf.edf") annotations = f.readAnnotations() print(annotations) # 返回三个数组: onset(起始时间), duration(持续时间), description(事件描述) for onset, dur, desc in zip(annotations[0], annotations[1], annotations[2]): if dur == -1: # EDF+ 中 -1 表示无持续时间 print(f"事件: {desc} 发生于 {onset} 秒") else: print(f"事件: {desc} 发生于 {onset} 秒, 持续 {dur} 秒")这里有个关键细节:readAnnotations返回的时间单位是秒,且是相对于文件起始时间的偏移量。如果你的后续分析需要把事件对齐到采样点,必须先拿到全局采样率再做换算——而不是直接用注解的时间戳作为索引。
5.2 时间戳换算为相对采样点:避免波形对齐误差
实际项目中,最常踩的坑是多导联睡眠记录里不同信号采样率不同。EEG 可能是 256 Hz,EOG 可能是 128 Hz,ECG 可能是 512 Hz。如果直接对注解时间戳统一乘一个采样率去做采样点对齐,通道间就会产生偏移。
推荐的换算方式是按通道独立计算:事件对应的采样点索引 = 时间戳 × 该通道采样率,然后按需取整。但注意取整误差。对于 1 秒的 EEG 数据,1/256 ≈ 0.0039 秒的误差,这是 EEG 分析中可以接受的精度;如果你要分析的是诱发响应(ERP),建议插值到高采样率后再对齐。
sfreq_eeg = f.getSampleFrequency(0) for onset, desc in zip(*f.readAnnotations()[:2]): sample_idx = int(onset * sfreq_eeg) print(f"{desc} -> EEG 采样点 {sample_idx}")5.3 EDF+ 多文件拼接时的分段记录不连续问题
另一个 EDF+ 独有的坑是多分段(datablock)文件。睡眠分期监测经常把一次整夜记录拆成多个 .edf 段文件,读取时要注意段间的时间戳不连续性。pyedflib不提供自动拼接功能,必须手动处理每段文件的时间偏移。
最简单的做法是逐段读取后在逻辑层拼接:维护一个全局时间偏移量,每读完一个文件,把该文件的注解时间戳加上偏移量,然后合并 DataFrame 或者 numpy 数组。如果各段文件的通道数或采样率完全一致,可以直接通过np.concatenate拼接;如果不一致,要先做重采样再拼。
6. 文件损坏与超大文件读取:edfread 在真实工程中的兜底方案
6.1 超大 EDF 文件的分块读取策略
整夜高采样率多导睡眠监测的 EDF 文件动辄 2GB 以上,一次性读入全部通道内存会直接飙升。pyedflib的readSignal支持起点和长度参数,可以按块读取。实践中推荐的做法是按记录时长分块,比如每次读入 30 秒的数据:
import pyedflib import numpy as np CHUNK_SECONDS = 30 f = pyedflib.EdfReader("huge_psg.edf") sfreq = f.getSampleFrequency(0) total_samples = f.getNSamples()[0] chunk_samples = int(sfreq * CHUNK_SECONDS) # 分块读取所有通道并做局部特征计算 for start in range(0, total_samples, chunk_samples): end = min(start + chunk_samples, total_samples) block_data = [] for ch in range(f.signals_in_file): chunk = f.readSignal(ch, start, end - start) block_data.append(chunk) # 在此处计算局部特征,而不是把所有块堆在内存里 block_data_np = np.vstack(block_data) # 伪代码示意:feature = compute_feature(block_data_np) del block_data_np注意readSignal(ch, start, n)的n是该通道需要读取的样本数,如果传end - start,而各通道采样率不一致,这个逻辑会出错。稳妥做法是对每个通道分别根据其采样率计算端点索引,不建议跨通道统一用同一起点长度。
6.2 损坏文件的局部恢复策略:坏头指令与自动纠偏
真实场景里,断电或存储介质故障导致的 EDF 文件损坏很常见。轻量级损坏通常发生在文件结尾,丢失几个数据块;严重损坏则会破坏头部字段。遇到这类文件的兜底策略是:
- 用
open(file, 'rb')直接读,按字节验证头部前 8 字节是否为 EDF 版本标识 - 头文件损坏时,尝试读取保留字段中可能存在的原始参数
- 数据区损坏时,pyedflib 会抛出
Sampling rate mismatch异常,此时可以将报错的通道单独降为float32或跳过该通道,保留其余通道的分析
以下是实践中的自动纠偏函数骨架:
def safe_read_edf(filepath): try: f = pyedflib.EdfReader(filepath) return f, "ok" except Exception as e: # EDF 头部解析失败时的降级方案 raw = open(filepath, 'rb').read(256) if not raw.startswith(b'0'): # 尝试跳过前导垃圾字节 idx = raw.find(b'0 ') if idx != -1: # 重新以跳过偏移量方式打开(pseudo-code) f = pyedflib.EdfReader(filepath, offset=idx) return f, "recovered" raise这种方法不是万能的,但它能在不破坏原始文件的前提下尽量把数据救出来。
6.3 读取完成的正确性验证:重采样对比与时间戳单调性检查
读取不是终点。读取完成后对数据质量做检查,才能避免把坏数据送进下游统计或深度学习模型里。推荐三个低成本检查项:
第一,重采样对比:隔一段抽查一个短窗口,计算该窗口均值与全文件均值的偏差,如果偏差超过 3 倍标准差,说明数据里有伪迹或读取错误。第二,时间戳单调性:读取注解通道,检查所有时间戳是否严格递增,如果有回退,说明文件内有段缺失。
import numpy as np def validate_timestamps(annotations): onsets = annotations[0] if not np.all(np.diff(onsets) > 0): bad_idx = np.where(np.diff(onsets) <= 0)[0] print(f"时间戳异常位置: {bad_idx}") return False return True第三,物理范围校验:对照信号头的物理最小/最大值,检查采样后的物理量是否超界。超界的部分不一定是读取错误,但标记出来对下游分析有提示价值。这三个检查加起来可能只需要几十毫秒,但能把读取层的问题和下游分析层的问题分割开,节省数小时的调试时间。
以这三个验证动作作为结尾,实际上已经把整条 EDF 读取链路覆盖到了“能判读结果是否正确”的程度,无论是对医学数据还是 FPGA 网表场景,读取之后的验证都比读取本身更值得投入时间。
本文还有配套的精品资源,点击获取