简介:这份医学图像分割数据集面向医学影像分析、深度学习分割算法实践者与相关课程学习者,聚焦肺部感染(COVID)区域的像素级分割任务,可用于训练与评估U-Net等分割网络。数据统一为256×256分辨率,前景为感染区域,mask采用前景255的二值图像,便于直观观察与损失计算,并在DRIVE数据集基础上做了扩充。压缩包共约2000个文件,以1998个png图像与标签为主,另含1个txt说明和1个py脚本,整体约80.6MB。数据按训练集1864张、验证集466张、测试集583张划分,images与masks目录一一对应,可直接接入主流训练流程。包内附带图像分割可视化脚本,随机抽取一张图片,展示原图、GT图像及GT在原图上的蒙板效果并保存到当前目录,方便快速核验标注质量。目前已有402人学习,适合需要现成分割数据与可视化工具的研究者和学生。
1. 肺部感染 COVID 图像分割数据集:2500 张片子能撑起一个可用的分割模型吗
手上只有一台 8G 显存的机器,导师丢过来一个任务:从胸部 X 光片里把肺部感染区域勾出来,最好这周能看到可视化结果。这时候你搜到「医学图像分割数据集:肺部感染(COVID)图像分割,约 2500 张数据和标签」,第一反应大概是——2500 张,够吗?能训出东西吗?标签是掩码还是框?我拿它做 COVID 病灶分割,到底是在做一件有临床意义的事,还是在做一个能跑通 demo 的玩具?
先把结论摆出来:2500 张带像素级标签的胸部 X 光片,在医学图像分割这个领域里属于「小而精」的规模。它不足以训练一个从零开始的深度网络,但足够做迁移学习下的微调、足够验证一套分割 pipeline 是否跑得通、也足够支撑一篇方法类论文的实验章节。真正决定成败的不是数据量,而是标签质量、预处理方式和损失函数的选择。这篇笔记就按「拿到数据集 → 看懂结构 → 跑通基线 → 调参避坑 → 验证效果」的顺序,把这条链路讲清楚,适合刚接触医学图像分割的工程师和研究生照着复现。
2. 先搞清楚这批 COVID 肺部数据集的标签结构再动手
2.1 医学图像分割里 mask、bbox、类别 id 到底怎么对应
拿到一个医学图像分割数据集,最忌讳的就是直接Image.open然后往网络里塞。你得先弄明白三件事:图像是什么模态、标签是什么粒度、类别是怎么编码的。肺部感染 COVID 图像分割这类数据集,常见做法是胸部 X 光片(CXR)作为输入,对应的标签是一张同尺寸的单通道掩码图,像素值 0 表示背景,255 或 1 表示感染区域。也有部分版本会区分「肺野」和「感染灶」两类,这时候掩码里就会出现 0/1/2 三个值。
这里有个容易被忽略的点:医学图像分割的标签往往不是「非黑即白」。放射科医生勾画病灶边界时,边缘像素本身就存在主观性,所以你会看到掩码边缘有灰度过渡。如果你用cv2.imread默认读成三通道,再除以 255,边缘的 128 这种值就会被当成 0.5 的软标签,训练时反而可能带来噪声。常见做法是读成灰度图后做二值化,阈值取 127。
import cv2 import numpy as np import os def load_mask(mask_path, threshold=127): # 以灰度模式读取,避免三通道带来的通道冗余 mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) if mask is None: raise FileNotFoundError(f"掩码读取失败: {mask_path}") # 二值化:大于阈值的算作前景,其余归为背景 mask = (mask > threshold).astype(np.uint8) return mask # 批量检查标签取值范围,确认是 0/1 还是 0/1/2 mask_dir = "./data/masks" for name in os.listdir(mask_dir)[:5]: m = cv2.imread(os.path.join(mask_dir, name), cv2.IMREAD_GRAYSCALE) print(name, "unique values:", np.unique(m))这段代码的作用是先做一次「标签体检」。threshold=127是针对边缘灰度过渡的常规取值,如果你的数据集掩码本身就是严格的 0/255,这个阈值不影响结果。np.unique打印出来的值域决定了你后面用几分类的损失函数:只有 0 和 1 就用二分类的 BCE 或 Dice,出现 0/1/2 就得换成多分类的 CrossEntropy 加 softmax。
2.2 用一条脚本把 2500 张图和标签对齐、去重、划分
2500 张这个量级,手动整理不现实,但也不能随便random.shuffle就切分。医学图像分割数据集常见的坑是:同一个病人的多张片子被分到了训练集和验证集,导致验证指标虚高。虽然这类公开数据集不一定提供病人 id,但你可以通过图像哈希做一次近似去重,至少把完全重复的样本剔掉。
import hashlib from sklearn.model_selection import train_test_split def file_hash(path): # 用文件内容的 md5 做近似去重,避免同一张图重复出现 with open(path, "rb") as f: return hashlib.md5(f.read()).hexdigest() image_dir = "./data/images" mask_dir = "./data/masks" # 假设图像和掩码同名,仅扩展名不同 names = [os.path.splitext(n)[0] for n in os.listdir(image_dir)] pairs = [] seen = set() for n in names: img_path = os.path.join(image_dir, n + ".png") mask_path = os.path.join(mask_dir, n + ".png") if not os.path.exists(mask_path): continue h = file_hash(img_path) if h in seen: continue seen.add(h) pairs.append((img_path, mask_path)) print(f"去重后有效样本: {len(pairs)}") # 按 8:1:1 划分,固定随机种子保证可复现 train, temp = train_test_split(pairs, test_size=0.2, random_state=42) val, test = train_test_split(temp, test_size=0.5, random_state=42) print(len(train), len(val), len(test))random_state=42是为了让每次划分结果一致,方便你复现实验。test_size=0.2再对半切,得到 8:1:1 的比例,这是医学图像分割里比较稳妥的划分。如果你的数据里阳性样本(有感染灶的)占比很低,还要考虑分层抽样,否则验证集里可能一张阳性都没有,Dice 直接算成 0。
2.3 预处理:为什么 resize 到 256 比 512 更适合小数据集
胸部 X 光片原始分辨率常见是 1024×1024 甚至更高。2500 张的规模下,如果你坚持用 512×512 训练,显存占用会翻四倍,batch size 被迫降到 2 或 4,BatchNorm 的统计量会非常不稳定。我一般会把输入统一 resize 到 256×256,掩码用最近邻插值保持标签的离散性。
def preprocess(img_path, mask_path, size=256): img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) # 图像用线性插值,掩码必须用最近邻,否则会引入不存在的类别值 img = cv2.resize(img, (size, size), interpolation=cv2.INTER_LINEAR) mask = cv2.resize(mask, (size, size), interpolation=cv2.INTER_NEAREST) img = img.astype(np.float32) / 255.0 mask = (mask > 127).astype(np.float32) return img, mask掩码插值这里是个血泪经验:用INTER_LINEAR会把 0 和 1 之间插出 0.3、0.7 这种值,二值化之后边界会漂移,Dice 指标会莫名其妙掉几个点。INTER_NEAREST保证掩码里只有 0 和 1。图像归一化到 [0,1] 是常规操作,如果你要用 ImageNet 预训练权重,还得再做一次均值方差标准化。
3. 用 U-Net 在 2500 张 COVID 数据上跑通第一条基线
3.1 为什么小数据集上 U-Net 比 Transformer 更稳
医学图像分割这个领域,近两年 Transformer 类方法很火,但在 2500 张这个量级上,我一般还是先上 U-Net。原因很直接:U-Net 的归纳偏置(局部性、平移等变性)和医学图像的纹理特征天然匹配,参数量小,不需要海量数据就能收敛。Transformer 需要更大的数据规模才能体现出全局建模的优势,小数据集上很容易过拟合,验证集 Dice 震荡得厉害。
U-Net 的结构不用我多讲,编码器下采样提取语义,解码器上采样恢复分辨率,跳跃连接把浅层细节直接送到对应层级。在肺部感染分割任务里,感染灶的边界往往比较模糊,跳跃连接提供的浅层边缘信息非常关键。如果你用现成的segmentation_models_pytorch,一行就能拿到 U-Net 加 ResNet34 编码器。
import torch import segmentation_models_pytorch as smp model = smp.Unet( encoder_name="resnet34", # 用 ImageNet 预训练编码器,小数据集必备 encoder_weights="imagenet", in_channels=1, # 胸部 X 光片是单通道灰度图 classes=1, # 二分类:背景 + 感染灶 activation=None # 损失函数里再套 sigmoid,避免数值不稳定 )encoder_weights="imagenet"是这套方案能跑通的关键。2500 张图从零训练,编码器根本学不出有意义的特征,用 ImageNet 预训练权重做初始化,相当于把自然图像里学到的边缘、纹理先验迁移过来。in_channels=1需要把预训练权重的第一层卷积做通道平均,smp会自动处理。activation=None是因为后面用BCEWithLogitsLoss,它内部自带 sigmoid,如果你在这里加了 sigmoid,等于做了两次,梯度会消失。
3.2 损失函数选 Dice + BCE 组合,别只用交叉熵
肺部感染区域在整张胸片里占比通常很小,可能只有 5% 到 15%。这种类别极度不平衡的场景下,纯 BCE 会让模型倾向于全部预测为背景,准确率看着很高,Dice 却接近 0。常见做法是 BCE 和 Dice Loss 按权重相加,BCE 负责像素级稳定梯度,Dice 负责优化区域重叠度。
import torch.nn as nn class BCEDiceLoss(nn.Module): def __init__(self, bce_weight=0.5): super().__init__() self.bce = nn.BCEWithLogitsLoss() self.bce_weight = bce_weight def forward(self, logits, targets): bce_loss = self.bce(logits, targets) # Dice 计算前先过 sigmoid,把 logits 转成概率 probs = torch.sigmoid(logits) probs = probs.view(probs.size(0), -1) targets = targets.view(targets.size(0), -1) intersection = (probs * targets).sum(dim=1) dice = (2 * intersection + 1e-6) / (probs.sum(dim=1) + targets.sum(dim=1) + 1e-6) dice_loss = 1 - dice.mean() return self.bce_weight * bce_loss + (1 - self.bce_weight) * dice_lossbce_weight=0.5是个经验起点,如果你发现训练前期 loss 下降很慢,可以调到 0.7 让 BCE 主导;如果验证集 Dice 上不去,调到 0.3 让 Dice 主导。1e-6是平滑项,防止分母为 0。这个损失函数我在多个医学分割任务里用过,比单独用任何一个都稳。
3.3 训练循环里必须记录 Dice 和 IoU,不能只看 loss
训练脚本里最容易犯的错是只打印 loss。loss 下降不代表分割质量提升,尤其在类别不平衡时,loss 可能一直在降但模型输出的全是背景。每个 epoch 结束后在验证集上算一次 Dice 和 IoU,才是真正能反映模型好坏的指标。
def compute_metrics(logits, targets, threshold=0.5): probs = torch.sigmoid(logits) preds = (probs > threshold).float() preds = preds.view(preds.size(0), -1) targets = targets.view(targets.size(0), -1) intersection = (preds * targets).sum(dim=1) dice = (2 * intersection + 1e-6) / (preds.sum(dim=1) + targets.sum(dim=1) + 1e-6) union = preds.sum(dim=1) + targets.sum(dim=1) - intersection iou = (intersection + 1e-6) / (union + 1e-6) return dice.mean().item(), iou.mean().item()threshold=0.5是默认阈值,但在医学分割里这个值不一定最优。你可以训练完后在验证集上扫一遍 0.3 到 0.7,选 Dice 最高的那个作为最终推理阈值。这个操作成本很低,但经常能带来一两个点的提升。
4. 训练肺部感染分割模型时最容易翻车的五个地方
4.1 现象:验证集 Dice 从第 5 个 epoch 开始一路下跌
原因:2500 张图对 U-Net 加 ResNet34 来说仍然偏少,编码器参数量大,几个 epoch 后就开始记住训练集。这是典型过拟合。
解决:先加数据增强,随机旋转 ±15 度、水平翻转、亮度对比度扰动,这些对胸片是安全的。再不行就冻结编码器前几层,只训解码器。如果还压不住,把编码器换成 ResNet18 或 MobileNetV2,参数量砍一半。
4.2 现象:训练 loss 正常下降,但推理时输出全黑或全白
原因:推理时忘了做 sigmoid,或者阈值设成了 0.5 但模型输出的是 logits,值域在 -10 到 10 之间,直接二值化当然全错。
解决:推理脚本里显式加torch.sigmoid,然后可视化几张预测掩码和原图叠加,肉眼确认。这个黑匣子问题不可视化很难发现。
4.3 现象:Dice 指标在 0.6 左右卡住,怎么调学习率都不动
原因:掩码边缘的模糊区域被当成了硬标签,模型在边界处反复震荡。或者数据增强里的旋转用了默认的双线性插值,把掩码插出了中间值。
解决:检查增强库的掩码插值参数,Albumentations 里要显式设mask_interpolation=cv2.INTER_NEAREST。另外可以试试在损失里加边界加权,给掩码边缘像素更高的权重。
4.4 现象:换了一批数据后,模型完全失效
原因:新数据的灰度分布和原数据集不一致,比如原数据是 8 位图,新数据是 16 位,归一化后数值范围差了几百倍。
解决:预处理阶段打印图像的 min/max/mean,确认归一化方式一致。跨数据集时最好重新算均值和方差,别直接套用 ImageNet 的统计量。
4.5 现象:显存溢出,batch size 只能设成 2
原因:输入分辨率 512×512 加上 U-Net 的跳跃连接,中间特征图占用很大。2500 张的规模下没必要用这么高的分辨率。
解决:降到 256×256,batch size 能提到 8 到 16,训练稳定性反而更好。如果一定要高分辨率,用梯度累积模拟大 batch,或者混合精度训练。
5. 把 Dice 从 0.72 推到 0.80 的三个具体技巧
第一个技巧是测试时增强(TTA)。推理时把原图、水平翻转图、±10 度旋转图分别送进模型,把预测概率平均后再二值化。这个操作不增加训练成本,在肺部感染分割这种边界模糊的任务上,通常能带来 1 到 2 个点的 Dice 提升。代价是推理时间翻几倍,但 2500 张的验证集完全等得起。
def predict_with_tta(model, image): # image: 1x1xHxW preds = [] preds.append(torch.sigmoid(model(image))) preds.append(torch.flip(torch.sigmoid(model(torch.flip(image, [3]))), [3])) avg = torch.stack(preds, dim=0).mean(dim=0) return (avg > 0.5).float()第二个技巧是后处理去小连通域。模型输出里经常有一些孤立的几个像素的假阳性,用cv2.connectedComponents把面积小于 50 像素的连通域直接抹掉。这个阈值要根据你的分辨率调,256×256 下 50 是个合理起点,512×512 下可以放到 200。
def remove_small_objects(mask, min_area=50): num, labels, stats, _ = cv2.connectedComponentsWithStats(mask.astype(np.uint8), connectivity=8) out = np.zeros_like(mask) for i in range(1, num): if stats[i, cv2.CC_STAT_AREA] >= min_area: out[labels == i] = 1 return out第三个技巧是阈值搜索。训练完后在验证集上把阈值从 0.3 扫到 0.7,步长 0.05,记录每个阈值下的 Dice,选最高的那个。我做过统计,最优阈值经常不在 0.5,而是在 0.4 或 0.45,因为模型对前景的预测偏保守。这三个技巧叠加起来,在 2500 张 COVID 肺部数据上把 Dice 从 0.72 推到 0.80 是现实的。
最后说个我自己的习惯:每次跑完实验,我一定把预测结果叠加到原图上存成 PNG,抽 20 张肉眼过一遍。指标再好看,如果模型把整个肺野都涂成感染区,那也是白搭。医学图像分割这件事,最终是要给人看的,别让 Dice 数字骗了自己。希望帮到你。
本文还有配套的精品资源,点击获取