☰
Matlab读取EDF文件:从edfread到自写解析的避坑指南
2026/10/19 2:44:54 网站建设 项目流程

简介:这份资源面向需要在Matlab环境中处理生物医学信号的科研人员、工程师与学生,聚焦EDF(欧洲数据格式)文件的读取与解析。EDF常用于心电图、脑电图等生理信号记录,Matlab本身没有内置读取函数,因此资源提供了可复用的解决思路与脚本。压缩包共2个文件,包含1个m脚本与1个txt说明文档,整体约3KB,其中m文件为核心读取函数,txt则给出导入命令与使用提示,便于快速上手。目前已有2399人学习下载,说明其在信号处理入门与实验场景中具有一定参考价值。读者可借此理解EDF文件头结构、通道数据组织方式,掌握借助第三方工具箱或自定义代码读取多通道信号的方法,并在此基础上开展滤波、特征提取、R波检测、功率谱分析等后续处理,适合作为Matlab生物医学信号处理的实用起点。

1. 为什么我劝你先别急着写edfread:Matlab 读取 EDF 文件的三层认知

如果你手头有一堆.edf文件,第一反应大概率是打开 Matlab 敲edfread。但真实情况是:Matlab 官方直到 R2021b 才把edfread收进信号处理工具箱,而大量实验室、医院导出的 EDF 文件来自不同厂商的采集设备,头字段格式、通道命名、注释通道、触发标记的写法五花八门。直接调用官方函数,轻则通道名乱码,重则时间轴错位、采样率对不上,最后画出来的波形和原始设备上看到的完全不是一回事。

这篇笔记围绕「Matlab 读取 EDF 文件」这件事,把 EDF/EDF+ 的文件结构、官方函数与自写解析的取舍、批量读取与时间轴对齐、以及几个我踩过的坑一次讲清楚。适合两类人:一是刚拿到 EDF 数据、想快速把信号读进 Matlab 做滤波或频谱分析的新手;二是已经用过edfread但被通道错位、注释丢失、大文件内存爆掉折腾过的熟手。读完你应该能自己判断:这份 EDF 到底该用官方函数、还是该自己按字节解析。

2. EDF 文件结构拆解:从 256 字节头到数据记录

2.1 EDF 头的固定字段与可变字段

EDF 格式本身不复杂,它的设计目标是「自描述、跨平台、定长头 + 定长数据记录」。整个文件由三部分组成:256 字节的固定主头、每个信号一行的信号头、以及按数据记录(data record)连续排列的采样数据。

主头里真正影响读取的字段有这几个:

字段字节位置含义常见坑
版本0-7通常是0有些设备写0后补空格,别用strtrim后比较数字
患者信息8-87自由文本含逗号、空格,解析时别按逗号硬切
记录数236-243数据记录条数可能写-1表示未知,需按文件大小反推
记录时长244-251每条记录秒数常见1,也有0.1,影响时间轴
信号数252-255通道数量必须和信号头行数一致

信号头是「按字段分块、按通道排列」的:先所有通道的标签,再所有通道的物理量纲,再所有通道的物理最小值、最大值,然后是数字最小值、最大值,最后是每个通道的采样点数。这种「列式存储」是新手最容易读错的地方——如果你按行去读,通道顺序会全乱。

2.2 数据记录的排列方式与时间轴

每条数据记录里,所有通道的采样点是按通道顺序依次拼接的。假设有 3 个通道,采样率分别是 256、256、1,记录时长 1 秒,那么一条记录里就是 256 + 256 + 1 = 513 个采样点,前 256 个属于通道 1,接着 256 个属于通道 2,最后 1 个属于通道 3。

时间轴的计算方式是:第 n 条记录对应的时间起点是(n-1) * recordDuration秒。这里有个反直觉的点——EDF 不存储每个采样点的绝对时间戳,只靠记录时长和记录序号推算。如果设备在采集过程中丢过包,或者记录时长写的是0.1而你以为1,时间轴就会整体缩放。

提示:拿到一个陌生 EDF 文件,先别读数据,先把主头和信号头打印出来,确认记录数、记录时长、每通道采样点数这三个值,再决定怎么读。

2.3 EDF+ 与普通 EDF 的差异

EDF+ 是在 EDF 基础上的扩展,主要多了两类特殊通道:注释通道(annotation channel)和触发通道(trigger channel)。注释通道的标签通常是EDF Annotations,它的采样数据不是生理信号,而是 TAL(Time-stamped Annotation List)格式的文本,里面记录了事件发生的时间、持续时间、事件类型。

很多公开数据集(比如睡眠分期数据)用 EDF+ 存储,事件标记全在注释通道里。如果你只用edfread读信号通道,注释会被当成普通信号读进来,数值全是乱码,事件信息就丢了。判断方法很简单:看信号头里有没有标签为EDF Annotations的通道,有就是 EDF+。

3. 用 Matlab 读取 EDF:官方函数与自写解析的取舍

3.1edfread的适用边界与调用方式

Matlab 官方edfread在 R2021b 之后随信号处理工具箱提供,基本调用形式是:

% 读取 EDF 文件,返回 timetable 和包含头信息的结构体 [data, header] = edfread('sample.edf'); % 查看头信息中的关键字段 disp(header.recordDuration); % 每条记录的时长(秒) disp(header.records); % 记录条数 disp(header.label); % 通道标签 % 查看数据的时间范围和通道名 disp(data.Properties.DimensionNames); disp(data.Properties.VariableNames);

这段代码的逻辑是:edfread把每个通道读成一个 timetable 变量,时间轴按记录时长自动生成,header里保留了主头和信号头的原始信息。参数方面,edfread还支持'DataRecordOutputType'指定输出为'timetable'或'vector',以及'TimeOutputType'指定时间格式。如果你的 Matlab 版本低于 R2021b,或者没装信号处理工具箱,这个函数根本不存在,会直接报Undefined function 'edfread'。

edfread的边界在于:它对 EDF+ 注释通道的处理并不总是符合预期,有些版本会把注释通道读成数值矩阵,TAL 文本被截断;另外它对记录数为-1的文件支持有限,可能报错或只读一部分。我一般会先用它快速验证文件能不能读,如果注释通道有问题,再换自写解析。

3.2 自写解析函数:按字节读头、按记录读数据

当官方函数不满足需求时,自己写一个解析函数并不难。核心思路是:用fread按字节读主头,再按字段读信号头,最后按记录循环读数据。下面是一个最小可用的解析框架:

function [signals, fs, t] = read_edf_basic(filename) fid = fopen(filename, 'r', 'ieee-le'); % EDF 是小端字节序 if fid == -1 error('无法打开文件: %s', filename); end % 读主头 256 字节 mainHeader.version = strtrim(char(fread(fid, 8, 'char')')); mainHeader.patient = strtrim(char(fread(fid, 80, 'char')')); mainHeader.recordInfo = strtrim(char(fread(fid, 80, 'char')')); mainHeader.startDate = strtrim(char(fread(fid, 8, 'char')')); mainHeader.startTime = strtrim(char(fread(fid, 8, 'char')')); mainHeader.headerBytes = str2double(char(fread(fid, 8, 'char')')); mainHeader.reserved = strtrim(char(fread(fid, 44, 'char')')); mainHeader.numRecords = str2double(char(fread(fid, 8, 'char')')); mainHeader.recordDuration = str2double(char(fread(fid, 8, 'char'))); mainHeader.numSignals = str2double(char(fread(fid, 4, 'char'))); % 读信号头:每个字段按通道数读 n = mainHeader.numSignals; labels = cell(n, 1); for i = 1:n labels{i} = strtrim(char(fread(fid, 16, 'char')')); end % 跳过 transducer、physical dimension 等字段,按需读取 fseek(fid, 80 * n, 'cof'); % 跳过 transducer 和 physical dimension physMin = zeros(n, 1); for i = 1:n physMin(i) = str2double(char(fread(fid, 8, 'char'))); end physMax = zeros(n, 1); for i = 1:n physMax(i) = str2double(char(fread(fid, 8, 'char'))); end digMin = zeros(n, 1); for i = 1:n digMin(i) = str2double(char(fread(fid, 8, 'char'))); end digMax = zeros(n, 1); for i = 1:n digMax(i) = str2double(char(fread(fid, 8, 'char'))); end samplesPerRecord = zeros(n, 1); for i = 1:n samplesPerRecord(i) = str2double(char(fread(fid, 8, 'char'))); end fseek(fid, 32 * n, 'cof'); % 跳过 reserved 字段 % 计算采样率 fs = samplesPerRecord / mainHeader.recordDuration; % 读数据:每条记录内按通道顺序拼接 totalSamples = mainHeader.numRecords * samplesPerRecord; signals = zeros(sum(totalSamples), 1); % 简化版,实际应按通道分开 % 这里只演示读取逻辑,完整实现需按通道分配 fclose(fid); end

这段代码的关键点有三个:一是fopen必须指定'ieee-le',EDF 是小端字节序,用默认的大端读出来全是错的;二是信号头的字段是「按字段分块、按通道排列」,所以每个字段都要循环n次;三是fseek跳过不需要的字段时,偏移量要按字段长度 * 通道数计算,算错了后面全乱。

参数说明:headerBytes通常是 256 + 信号头总字节数,可以用来校验;numRecords为-1时,需要用(文件总字节数 - headerBytes) / 每条记录字节数反推;recordDuration决定时间轴缩放,常见值是1,但睡眠数据里也有0.1或30。

3.3 物理值与数字值的换算

EDF 存储的是数字值(ADC 原始值),要还原成物理值(比如微伏),需要按下面的公式换算:

% 物理值 = (数字值 - digMin) * (physMax - physMin) / (digMax - digMin) + physMin physicalValue = (digitalValue - digMin(i)) * ... (physMax(i) - physMin(i)) / (digMax(i) - digMin(i)) + physMin(i);

这个换算每个通道独立,physMin、physMax、digMin、digMax都来自信号头。如果digMax == digMin,说明这个通道没有有效数据,换算会除零,需要跳过。我见过一些设备把注释通道的digMin和digMax都写成0,如果不判断,直接算会得到NaN。

4. 批量读取与时间轴对齐:把 200 个 EDF 变成一张表

4.1 批量读取的目录遍历与容错

实际项目里往往不是读一个文件,而是读一个目录下几百个 EDF。批量读取的核心是「遍历 + 容错 + 汇总」,下面是一个可复用的框架:

function allData = batch_read_edf(folderPath) files = dir(fullfile(folderPath, '*.edf')); allData = struct('name', {}, 'signals', {}, 'fs', {}, 'startTime', {}); for k = 1:length(files) fname = fullfile(folderPath, files(k).name); try [sig, fs, t] = read_edf_basic(fname); allData(k).name = files(k).name; allData(k).signals = sig; allData(k).fs = fs; allData(k).startTime = t(1); catch ME fprintf('读取失败: %s, 原因: %s\n', files(k).name, ME.message); continue; end end end

这段代码的逻辑是:用dir拿到所有.edf文件,逐个调用解析函数,用try-catch包住,单个文件失败不影响整体。参数方面,folderPath建议用绝对路径,避免相对路径在不同工作目录下失效;catch里打印文件名和错误信息,方便事后排查是哪个文件格式异常。

注意:批量读取时不要一次性把所有信号堆在内存里。如果每个文件几百 MB,200 个文件就是几十 GB,Matlab 会直接爆内存。常见做法是读一个、处理一个、只保留特征或降采样后的结果。

4.2 时间轴对齐:起始时间与采样率不一致怎么办

多个 EDF 文件拼接时,最大的坑是起始时间不一致。EDF 主头里有startDate和startTime,格式是dd.mm.yy和hh.mm.ss。如果两个文件是同一天连续采集的,可以直接按记录时长累加;如果跨天或者设备时钟不准,就需要用注释通道里的时间戳来对齐。

对齐的基本步骤是:先把每个文件的起始时间转成datetime,再按采样率生成每个采样点的绝对时间,最后用synchronize或interp1把不同采样率的通道统一到同一时间轴。如果采样率是整数倍关系,直接降采样;如果不是,用resample做重采样,别用interp1硬插,否则高频成分会混叠。

4.3 内存优化:memmapfile与分块读取

对于超大 EDF 文件,一次性fread会把整个文件读进内存。更稳妥的做法是用memmapfile做内存映射,或者分块读取:

% 分块读取:每次读 100 条记录 chunkSize = 100; numChunks = ceil(mainHeader.numRecords / chunkSize); for c = 1:numChunks startRecord = (c-1) * chunkSize + 1; endRecord = min(c * chunkSize, mainHeader.numRecords); % 定位到起始记录的字节位置 offset = mainHeader.headerBytes + (startRecord-1) * bytesPerRecord; fseek(fid, offset, 'bof'); % 读取当前块的记录 chunkData = fread(fid, (endRecord-startRecord+1) * sum(samplesPerRecord), 'int16'); % 处理 chunkData ... end

这里bytesPerRecord等于sum(samplesPerRecord) * 2,因为 EDF 数据是 16 位整数,每个采样点占 2 字节。分块读取的好处是内存占用可控,坏处是需要自己管理记录边界,别把一条记录拆到两个块里。

5. 避坑与排查:EDF 读取中最容易翻车的五个点

5.1 通道名乱码或截断

现象:读出来的通道标签是EEG Fpz-而不是EEG Fpz-Cz,或者出现方框字符。原因是 EDF 信号头的标签字段固定 16 字节,有些设备写入时没有补空格,或者用了非 ASCII 字符。解决方法是读的时候用char(fread(fid, 16, 'char')')然后strtrim,如果还有乱码,检查文件编码,必要时用native2unicode转换。

5.2 时间轴整体偏移或缩放

现象:波形看起来对,但时间轴比实际长 10 倍或短 10 倍。原因是recordDuration读错了,或者把recordDuration当成了采样率。解决方法是打印mainHeader.recordDuration和samplesPerRecord,手动验算fs = samplesPerRecord / recordDuration,确认无误后再生成时间轴。

5.3 注释通道被当成信号读入

现象:数据里多了一个通道,数值全是0或NaN,或者出现奇怪的整数。原因是 EDF+ 的注释通道标签是EDF Annotations,它的采样数据是 TAL 文本,不是生理信号。解决方法是在解析信号头时判断标签,遇到EDF Annotations就单独处理,按 TAL 格式解析事件时间和类型,不要混入信号矩阵。

5.4 记录数为-1导致读取失败

现象:edfread报错或只读了前几条记录。原因是主头的numRecords字段写的是-1,表示记录数未知。解决方法是先获取文件总字节数,减去headerBytes,再除以每条记录的字节数,反推实际记录数:

fileInfo = dir(filename); fileSize = fileInfo.bytes; bytesPerRecord = sum(samplesPerRecord) * 2; actualRecords = floor((fileSize - mainHeader.headerBytes) / bytesPerRecord);

5.5 大文件读取时内存溢出

现象:Matlab 卡死或报Out of memory。原因是一次性fread了整个文件的数据。解决方法是分块读取,或者用memmapfile做内存映射,只把需要的通道和时段读进来。如果只是做频谱分析,可以先降采样再读,没必要保留原始采样率。

6. 进阶技巧:用注释通道还原事件标记并验证读取正确性

注释通道的 TAL 格式看起来吓人,其实结构很规整。每条注释以+或-开头,后面跟起始时间、持续时间、事件文本,用\x14分隔。下面是一个解析 TAL 的实用函数:

function events = parse_tal(annotationSignal, fs) % annotationSignal 是注释通道的原始数字值 % 先转成字符 rawText = char(annotationSignal(:)'); % TAL 以 0x14 分隔,0x00 是填充 parts = strsplit(rawText, char(20)); events = struct('onset', {}, 'duration', {}, 'text', {}); for i = 1:length(parts) p = parts{i}; if isempty(p) || all(p == 0) continue; end % 去掉填充的 0 p = p(p ~= 0); % 按 0x14 分隔 onset 和 text tokens = strsplit(p, char(20)); if length(tokens) < 2 continue; end onset = str2double(tokens{1}); text = tokens{2}; events(end+1).onset = onset; events(end).text = text; end end

这段代码的逻辑是:把注释通道的数值转成字符,按0x14分隔符切分,每段里第一个字段是起始时间,第二个字段是事件文本。参数fs在这里其实用不到,因为 TAL 里的时间已经是秒为单位,但保留这个参数是为了和信号通道的时间轴对齐时做校验。

验证读取是否正确,我一般会做三件事:一是把读出来的信号画出来,看波形幅度和形态是否合理;二是检查时间轴总长度是否等于numRecords * recordDuration;三是把注释通道里的事件时间标在波形上,看是否和肉眼可见的伪迹或事件对齐。如果这三步都过了,基本可以确认读取没问题。

从那以后我每次拿到新的 EDF 文件,都强制先跑一遍头信息打印和注释通道解析,确认记录数、记录时长、通道标签、事件时间这四个值都对得上,再开始做滤波和分析。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询