简介:本资源是面向医学图像分析与深度学习初学者、研究者的MIT-BIH ECG心电图分类专用验证集与测试集,专为图像分类模型性能评估与泛化能力验证设计。数据集基于权威MIT-BIH心电数据库构建,共包含50,804张高质量PNG格式心电图图像(验证集30,421张、测试集20,383张),辅以1个类别映射JSON文件(class_indices.json)及1个可视化Python脚本,支持开箱即用的分类任务评测。压缩包总计2000个文件,主体为PNG图像(1998个),结构清晰:data-val与data-test目录均采用标准ImageFolder格式,按疾病类别分文件夹组织,便于PyTorch/TensorFlow等框架直接加载。目前已有1450人学习下载,配套类别字典与规范目录结构显著降低数据预处理门槛,节省特征提取与标签对齐时间,助力快速开展心律失常识别模型的验证与对比实验。
1. 项目缘起:当心电信号遇上图像分类
最近在做一个关于心律失常自动检测的辅助诊断项目,核心思路是把一维的心电信号转换成二维的图像,然后扔给卷积神经网络去做分类。这个想法听起来挺酷,但第一步就卡住了:数据。心电数据本身不难找,MIT-BIH心律失常数据库几乎是这个领域的“标准答案”,但我要的是图像分类数据集,而且是已经划分好验证集和测试集的。这就意味着,我需要的不只是原始的.dat/.hea文件,而是经过预处理、信号转图像、并且按照机器学习标准流程分割好的图像文件集合。
市面上能找到的MIT-BIH资源,99%都是针对信号处理的,教你如何用Python的wfdb库读取信号、做滤波、提取R波。但关于如何系统地将其构建成一个用于图像分类任务的、可直接喂给PyTorch或TensorFlow的ImageFolder格式数据集,资料就非常零散了。更别提验证集和测试集的划分,这直接关系到模型评估的可靠性和泛化能力的判断。所以,我花了差不多一周时间,从零开始,把MIT-BIH Arrhythmia Database“改造”成了一个标准的图像分类数据集。这个过程踩了不少坑,也总结了一套还算靠谱的流程,今天就把从数据下载、预处理、信号转图像、到数据集划分的完整链路,以及其中的核心细节和避坑指南,毫无保留地分享出来。
2. 理解核心原料:MIT-BIH心律失常数据库解剖
在动手“烹饪”数据集之前,必须彻底了解“食材”。MIT-BIH心律失常数据库(MIT-BIH Arrhythmia Database)发布于1980年,由麻省理工学院和波士顿贝斯以色列医院联合创建,它早已成为评估心律失常自动分析算法的黄金标准。
2.1 数据库的基本构成与文件格式
这个数据库包含48条双通道的动态心电图记录,每条记录时长约30分钟。这些记录来自47位不同的受试者(其中一条记录201和202来自同一位受试者)。数据以特定的物理格式存储,我们最常打交道的是以下几种文件:
- .dat 文件:这是二进制文件,存储了实际的采样信号数据。MIT-BIH的采样频率是360 Hz,即每秒采集360个点。信号通常以212格式存储,这是一种为了节省空间设计的格式,它将两个12位的样本(来自两个导联)打包成三个8位的字节。
- .hea 文件:头文件,纯文本格式。这是理解.dat文件的“说明书”。它包含了至关重要的元信息,例如:
采样频率:固定为360 Hz。信号数量:通常是2(MLII导联和V1导联,但部分记录可能不同)。信号增益:用于将存储的整数值转换为实际的物理单位(毫伏)。ADC零点和ADC分辨率:与信号值转换相关。每条记录的起始时间、患者信息等。
- .atr 文件:注释文件,同样是二进制格式。它包含了由心脏病专家手动标注的心跳类型和节律信息。这是整个数据库的精华所在,是我们制作分类标签的唯一依据。标注遵循AAMI标准,将心跳分为多个类别,如正常搏动(N)、左束支传导阻滞(L)、右束支传导阻滞(R)、房性早搏(A)、室性早搏(V)等。
注意:直接下载的原始压缩包(如
mit-bih-arrhythmia-database-1.0.0.zip)里通常就包含这三种文件。我们的所有工作都基于这些文件展开。
2.2 从心跳标注到图像分类标签
我们的目标是图像分类,所以分类的单元是“一张图”,而MIT-BIH标注的基本单元是“一个心跳”。因此,核心任务变成了:如何根据.atr文件中的心跳标注,为每一个心跳(或每一段心电信号)生成一个分类标签,并截取对应的信号段转换为图像。
这里有几个关键决策点:
心跳类型的映射与归并:AAMI标准的心跳类型有几十种,但对于一个稳健的分类器,我们通常需要将其归并为几个主要的临床大类。一个非常常见的映射方案是:
- N (Normal): 正常心跳。
- S (Supraventricular): 室上性异位搏动,包括A(房性早搏)、a(房性逸搏)、J(交界性早搏)等。它们起源自心室以上,风险相对较低。
- V (Ventricular): 室性异位搏动,包括V(室性早搏)、E(室性逸搏)等。起源于心室,有较高的临床意义。
- F (Fusion): 融合搏动,通常是心室融合波。
- Q (Unknown): 无法分类的搏动,包括“/”(起搏心跳)等。 这种5分类(N, S, V, F, Q)是很多论文采用的基准方案。你需要根据你的项目目标来决定分类体系。
信号段的截取窗口:以一个心跳的R波峰值位置(由.atr文件给出)为中心,向前向后各截取一段信号。窗口大小的选择需要权衡:太短可能丢失P波或T波信息;太长则包含过多无关信号且增加计算量。常见的选择是R峰前后各100-150个采样点(对应约0.28-0.42秒),这样通常能囊括一个完整的心跳波形。
导联的选择:MIT-BIH是双导联。通常MLII导联的波形更清晰、更标准,是首选。你可以选择单导联(MLII)进行分析,也可以尝试将双导联作为两个通道(类似图像的RGB通道)输入网络,但这需要调整网络输入层。
3. 实战构建:从原始信号到标准图像数据集
理解了原理,我们开始动手。整个流程可以概括为:下载 -> 读取 -> 截段 -> 转图 -> 保存 -> 划分。我将使用Python和几个核心库来完成。
3.1 环境准备与核心工具库
首先,确保你的环境里有这些工具:
# 基础数据处理和科学计算 pip install numpy pandas matplotlib # 专门用于读取MIT-BIH等生理信号的库,这是最关键的工具 pip install wfdb # 图像处理库 pip install pillow # 深度学习框架(以PyTorch为例,用于最后的DataLoader演示) pip install torch torchvisionwfdb库是这个过程中的“瑞士军刀”,它能直接解析.dat,.hea,.atr文件,省去了我们手动解析二进制格式的麻烦。
3.2 分步代码实现与详解
假设我们已经将MIT-BIH数据库的48条记录下载到了本地./mit-bih-arrhythmia-database/目录下。
步骤一:读取单条记录的信号和标注
import wfdb import numpy as np import matplotlib.pyplot as plt from PIL import Image import os # 定义记录路径(例如第一条记录100) record_name = './mit-bih-arrhythmia-database/100' record_path = './mit-bih-arrhythmia-database/' # 使用wfdb读取记录 record = wfdb.rdrecord(record_name, pn_dir='mit-bih-arrhythmia-database') annotation = wfdb.rdann(record_name, 'atr', pn_dir='mit-bih-arrhythmia-database') # 查看信息 print(f"信号长度: {record.sig_len}") print(f"采样频率: {record.fs}") print(f"信号名称: {record.sig_name}") print(f"标注数量: {len(annotation.symbol)}") print(f"前10个标注符号: {annotation.symbol[:10]}") print(f"前10个标注位置(采样点): {annotation.sample[:10]}")这段代码会输出记录的基本信息和前几个心跳的标注。annotation.sample数组里存储的就是每个心跳R峰的采样点位置,annotation.symbol是对应的AAMI标注符号。
步骤二:定义心跳类型映射和截取函数
# 定义AAMI到我们目标类别的映射字典 # 这里采用常见的5分类方案 label_map = { 'N': 'N', # Normal 'L': 'N', # Left bundle branch block beat (通常归为正常变体) 'R': 'N', # Right bundle branch block beat 'e': 'N', # Atrial escape beat 'j': 'N', # Nodal (junctional) escape beat 'A': 'S', # Atrial premature beat 'a': 'S', # Aberrated atrial premature beat 'S': 'S', # Supraventricular premature beat 'J': 'S', # Nodal (junctional) premature beat 'V': 'V', # Premature ventricular contraction 'E': 'V', # Ventricular escape beat 'F': 'F', # Fusion of ventricular and normal beat '/': 'Q', # Paced beat 'f': 'Q', # Fusion of paced and normal beat 'Q': 'Q', # Unclassifiable beat # 其他不常见的符号可以忽略或归为'Q' } def extract_beat_segment(signal, r_peak_idx, fs=360, window_before=100, window_after=150): """ 以R峰为中心,截取心拍信号段。 参数: signal: 一维心电信号数组。 r_peak_idx: R峰位置的索引。 fs: 采样频率,默认360Hz。 window_before: R峰前截取的采样点数。 window_after: R峰后截取的采样点数。 返回: beat_segment: 截取出的心拍信号段,一维数组。 """ start_idx = r_peak_idx - window_before end_idx = r_peak_idx + window_after # 处理边界情况:如果R峰太靠前或太靠后,无法截取完整窗口,则返回None if start_idx < 0 or end_idx > len(signal): return None return signal[start_idx:end_idx] def plot_and_save_beat(beat_signal, save_path, dpi=100): """ 将心拍信号绘制成图像并保存。 关键技巧:去除坐标轴和边框,让图像只包含波形本身。 """ fig, ax = plt.subplots(figsize=(2.56, 2.56), dpi=dpi) # 生成256x256像素的图像 ax.plot(beat_signal, linewidth=1.5, color='black') # 核心:去除所有坐标轴和边框 ax.axis('off') ax.set_ylim([beat_signal.min() - 0.1, beat_signal.max() + 0.1]) # 稍微留点边 ax.set_xlim([0, len(beat_signal)]) # 调整子图参数,确保波形充满画布 fig.subplots_adjust(left=0, right=1, top=1, bottom=0) # 保存图像 fig.savefig(save_path, pad_inches=0, bbox_inches='tight', facecolor='white') plt.close(fig) # 关闭图形,释放内存实操心得:
plot_and_save_beat函数中的ax.axis('off')和fig.subplots_adjust是关键。这能确保我们保存的是一张“干净”的波形图,而不是带有坐标轴的图表。这对于CNN模型至关重要,因为坐标轴和刻度线会成为无意义的干扰特征。dpi和figsize共同决定了输出图像的像素尺寸(这里是2.56英寸*100dpi=256像素)。
步骤三:遍历所有记录,生成图像数据集
现在,我们编写主循环,处理48条记录,生成图像并保存到以类别命名的文件夹中。
# 配置参数 data_dir = './mit-bih-arrhythmia-database/' output_dir = './mit_bih_ecg_images/' window_before = 100 window_after = 150 selected_channel = 0 # 选择MLII导联(通常是第0通道) # 创建输出目录结构(按类别分文件夹) class_names = ['N', 'S', 'V', 'F', 'Q'] for cls in class_names: os.makedirs(os.path.join(output_dir, cls), exist_ok=True) # 用于统计的字典 beat_count = {cls: 0 for cls in class_names} skipped_beats = 0 # 获取所有记录名(排除扩展名) record_names = [f.split('.')[0] for f in os.listdir(data_dir) if f.endswith('.hea')] record_names = list(set(record_names)) # 去重 for r_name in record_names: try: print(f"处理记录: {r_name}") # 读取信号和标注 record = wfdb.rdrecord(os.path.join(data_dir, r_name)) annotation = wfdb.rdann(os.path.join(data_dir, r_name), 'atr') # 提取选择的导联信号 ecg_signal = record.p_signal[:, selected_channel] # 遍历所有标注 for i, (sample, symbol) in enumerate(zip(annotation.sample, annotation.symbol)): # 映射标签 mapped_label = label_map.get(symbol, None) # 只处理我们定义的类别 if mapped_label is None or mapped_label not in class_names: continue # 截取心拍段 beat = extract_beat_segment(ecg_signal, sample, window_before=window_before, window_after=window_after) if beat is None: skipped_beats += 1 continue # 生成唯一文件名(使用记录名和R峰位置) filename = f"{r_name}_{sample:06d}.png" save_path = os.path.join(output_dir, mapped_label, filename) # 绘制并保存图像 plot_and_save_beat(beat, save_path) # 计数 beat_count[mapped_label] += 1 except Exception as e: print(f"处理记录 {r_name} 时出错: {e}") continue print("\n===== 数据生成完成 =====") print("各类别心拍数量统计:") for cls, count in beat_count.items(): print(f" {cls}: {count}") print(f"因边界问题跳过的心拍数: {skipped_beats}")运行这段代码后,你会在./mit_bih_ecg_images/目录下得到N/,S/,V/,F/,Q/五个文件夹,里面装满了对应的PNG格式心电图像。一个初步的图像分类数据集就成型了。
4. 数据集划分的科学:构建可靠的验证集与测试集
有了原始的图像数据,接下来是最关键也最容易出错的一步:划分训练集、验证集和测试集。很多新手会直接在整个数据集上随机划分,这在医学数据上,尤其是MIT-BIH这种特定数据库上,是严重的方法错误,会导致结果虚高、不可信。
4.1 为什么不能简单随机划分?
MIT-BIH数据库的48条记录来自47位病人。不同病人的心电特征差异巨大(个体生理差异、疾病状态、噪声类型等)。如果我们在所有心跳图像中随机划分,那么很可能同一个病人的心跳既出现在训练集,又出现在验证集和测试集。模型在训练时已经“见过”这位病人的特征模式,在测试时自然表现很好,但这并不是真正的泛化能力,而是“记住了病人”。这被称为“病人间数据泄露”,是医学图像/信号分析中最常见的陷阱之一。
正确的做法是:按记录(即按病人)进行划分。确保用于训练、验证、测试的心跳来自完全不同的病人子集。
4.2 按记录划分的具体策略与代码实现
MIT-BIH数据库本身有一个非官方的、但被广泛接受的按记录划分建议,旨在确保各类心跳在训练和测试集中都有代表性。一个经典的划分是:
- DS1 (训练集): 记录编号: 101, 106, 108, 109, 112, 114, 115, 116, 118, 119, 122, 124, 201, 203, 205, 207, 208, 209, 215, 220, 223, 230
- DS2 (测试集): 记录编号: 100, 103, 105, 111, 113, 117, 121, 123, 200, 202, 210, 212, 213, 214, 219, 221, 222, 228, 231, 232, 233, 234
这个划分(通常称为“Inter-Patient”划分)被大量学术论文采用作为基准。我们可以在此基础上,从DS1中再分出一部分作为验证集。
import shutil from sklearn.model_selection import train_test_split # 定义官方建议的划分(按记录号) ds1_records = ['101', '106', '108', '109', '112', '114', '115', '116', '118', '119', '122', '124', '201', '203', '205', '207', '208', '209', '215', '220', '223', '230'] ds2_records = ['100', '103', '105', '111', '113', '117', '121', '123', '200', '202', '210', '212', '213', '214', '219', '221', '222', '228', '231', '232', '233', '234'] # 我们的所有记录 all_records = ds1_records + ds2_records # 最终划分目标目录 final_dataset_dir = './mit_bih_final_dataset/' splits = ['train', 'val', 'test'] for split in splits: for cls in class_names: os.makedirs(os.path.join(final_dataset_dir, split, cls), exist_ok=True) # 步骤1: 将DS2直接作为测试集 test_records = ds2_records # 步骤2: 从DS1中划分训练集和验证集(例如 80%训练,20%验证) # 为了保持类别分布,可以使用分层抽样,但这里按记录划分已经隐含了这一点。 train_records, val_records = train_test_split(ds1_records, test_size=0.2, random_state=42) print(f"训练集记录数: {len(train_records)}") print(f"验证集记录数: {len(val_records)}") print(f"测试集记录数: {len(test_records)}") # 定义一个函数,根据记录名将图像文件复制到对应的划分文件夹 def copy_images_by_record(source_image_dir, record_list, target_split): """ source_image_dir: 之前生成的 ./mit_bih_ecg_images/ 目录 record_list: 属于该划分的记录名列表 target_split: 'train', 'val', 或 'test' """ for cls in class_names: class_dir = os.path.join(source_image_dir, cls) image_files = [f for f in os.listdir(class_dir) if f.endswith('.png')] for img_file in image_files: # 从文件名中提取记录名(文件名格式: 记录名_R峰位置.png) record_name_from_file = img_file.split('_')[0] if record_name_from_file in record_list: src_path = os.path.join(class_dir, img_file) dst_path = os.path.join(final_dataset_dir, target_split, cls, img_file) shutil.copy2(src_path, dst_path) # 执行复制 print("正在复制训练集图像...") copy_images_by_record(output_dir, train_records, 'train') print("正在复制验证集图像...") copy_images_by_record(output_dir, val_records, 'val') print("正在复制测试集图像...") copy_images_by_record(output_dir, test_records, 'test') print("数据集划分完成!")4.3 划分后的数据统计与类别不平衡问题
划分完成后,务必检查每个集合中各个类别的样本数量。
import collections def count_samples(split_dir): split_path = os.path.join(final_dataset_dir, split_dir) count = {} total = 0 for cls in class_names: cls_dir = os.path.join(split_path, cls) num = len([f for f in os.listdir(cls_dir) if f.endswith('.png')]) count[cls] = num total += num return count, total train_counts, train_total = count_samples('train') val_counts, val_total = count_samples('val') test_counts, test_total = count_samples('test') print("===== 数据集统计 =====") print(f"训练集总计: {train_total}") for cls in class_names: print(f" {cls}: {train_counts[cls]} ({train_counts[cls]/train_total:.2%})") print(f"\n验证集总计: {val_total}") for cls in class_names: print(f" {cls}: {val_counts[cls]} ({val_counts[cls]/val_total:.2%})") print(f"\n测试集总计: {test_total}") for cls in class_names: print(f" {cls}: {test_counts[cls]} ({test_counts[cls]/test_total:.2%})")你大概率会发现严重的类别不平衡。例如,“N”(正常)类别的样本数可能是“V”(室性早搏)的数十倍甚至上百倍。这是心电数据的固有特性,也是模型训练的一大挑战。
应对策略:
- 在损失函数上处理:使用
加权交叉熵损失(Weighted CrossEntropyLoss),给少数类别更高的权重。 - 在数据加载上处理:使用
加权随机采样(WeightedRandomSampler),确保每个batch内各类别样本出现的概率接近。 - 数据增强:对少数类别的图像进行适度的数据增强(如小幅度的横向拉伸压缩模拟心率变化、添加轻微噪声等),但需谨慎,避免改变心跳的临床形态特征。
5. 集成到深度学习框架:创建PyTorch DataLoader
数据集准备好了,划分也完成了,最后一步就是将其包装成深度学习框架能直接使用的形式。以PyTorch为例,我们需要创建一个自定义的Dataset类。
import torch from torch.utils.data import Dataset, DataLoader from torchvision import transforms from PIL import Image class MITBIHImageDataset(Dataset): """MIT-BIH 心电图像数据集类""" def __init__(self, split_dir, transform=None): """ 参数: split_dir: 划分目录,如 './mit_bih_final_dataset/train/' transform: 可选的图像变换组合 """ self.split_dir = split_dir self.transform = transform # 收集所有图像路径和标签 self.image_paths = [] self.labels = [] self.class_to_idx = {cls: idx for idx, cls in enumerate(class_names)} # 将类别名映射为索引 for cls in class_names: cls_dir = os.path.join(split_dir, cls) if not os.path.exists(cls_dir): continue for img_name in os.listdir(cls_dir): if img_name.endswith('.png'): self.image_paths.append(os.path.join(cls_dir, img_name)) self.labels.append(self.class_to_idx[cls]) def __len__(self): return len(self.image_paths) def __getitem__(self, idx): img_path = self.image_paths[idx] label = self.labels[idx] # 使用PIL打开图像,转换为灰度(L模式)或RGB image = Image.open(img_path).convert('L') # 转换为单通道灰度图 if self.transform: image = self.transform(image) return image, label # 定义图像预处理变换 # 注意:我们的图像已经是“干净”的波形图,预处理应尽量简单 train_transform = transforms.Compose([ transforms.Grayscale(num_output_channels=1), # 确保是单通道 transforms.Resize((224, 224)), # 调整到网络输入尺寸,如224x224 transforms.ToTensor(), # 转换为Tensor,并归一化到[0,1] # 可以添加归一化,但需要预先计算数据集的均值和标准差 # transforms.Normalize(mean=[0.5], std=[0.5]) ]) val_test_transform = transforms.Compose([ transforms.Grayscale(num_output_channels=1), transforms.Resize((224, 224)), transforms.ToTensor(), # transforms.Normalize(mean=[0.5], std=[0.5]) ]) # 创建数据集实例 train_dataset = MITBIHImageDataset('./mit_bih_final_dataset/train/', transform=train_transform) val_dataset = MITBIHImageDataset('./mit_bih_final_dataset/val/', transform=val_test_transform) test_dataset = MITBIHImageDataset('./mit_bih_final_dataset/test/', transform=val_test_transform) print(f"训练集大小: {len(train_dataset)}") print(f"验证集大小: {len(val_dataset)}") print(f"测试集大小: {len(test_dataset)}") # 创建DataLoader # 为了解决类别不平衡,可以定义加权采样器 from torch.utils.data import WeightedRandomSampler # 计算每个样本的权重(与所属类别的样本数成反比) train_labels = [label for _, label in train_dataset] class_counts = torch.bincount(torch.tensor(train_labels)) class_weights = 1. / class_counts.float() sample_weights = class_weights[train_labels] train_sampler = WeightedRandomSampler(sample_weights, len(sample_weights), replacement=True) train_loader = DataLoader(train_dataset, batch_size=32, sampler=train_sampler) # 使用采样器时不要用shuffle val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False) test_loader = DataLoader(test_dataset, batch_size=32, shuffle=False) # 现在,train_loader, val_loader, test_loader 就可以直接用于模型训练和评估了。至此,一个源自MIT-BIH心律失常数据库、经过规范预处理、按病人划分了验证集和测试集、并封装成PyTorch DataLoader的心电图像分类数据集就完全构建成功了。你可以直接将其用于训练ResNet、EfficientNet等图像分类模型,进行心律失常的自动分类研究。整个过程的核心在于理解数据背后的医学逻辑(按病人划分)和工程细节(干净的图像生成),避免数据泄露,才能得到可信的模型评估结果。
本文还有配套的精品资源,点击获取