COVID-19 CT图像分割实战:从2500张数据集到UNet模型训练全流程

发布时间:2026/10/5 9:52:35
COVID-19 CT图像分割实战:从2500张数据集到UNet模型训练全流程 简介面向医学图像分割研究的肺部感染COVID图像分割数据集聚焦256×256分辨率下的感染区域提取可服务于深度分割模型的训练、比较与教学演示。该数据集在公开分割数据的基础上扩充而来共约2500张图片及对应标签并按训练、验证、测试明确划分训练集1864张图像与1864张mask验证集466张图像与466张mask测试集583张图像与583张mask。所有mask均为前景灰度255的二值图像与同名原图一一对应且训练、验证、测试三部分均配齐独立的images与masks目录结构清晰可直接作为分割模型的输入数据。压缩包约80.6MB共2000个文件以1998个png图像为主体另含1个txt说明文件和1个Python可视化脚本txt文件说明数据目录与格式py脚本可随机读取一张图像并展示原图、GT及GT在原图上的蒙板效果便于快速核对。已有403人学习下载适合医学影像分割入门与进阶研究者使用。1. 医学图像分割数据集一张肺部感染COVIDCT图怎么变成能训练模型的2500张标注样本拿到一套肺部感染COVID图像分割数据集——约2500张图像和配套标签——第一反应别急着丢进模型里训练。分割任务和分类任务不同分类只要告诉你这张图有没有病灶分割要回答病灶具体长在哪几个像素上。COVID影像分割的难点在于病灶边界模糊、形状不规则、与正常肺组织对比度低数据质量直接决定模型上限。这套约2500张的数据规模在医学分割领域属于小而精的典型量级足矣训练一个UNet或其变体但前提是你要把数据格式、标签语义、预处理流程全部搞清楚。我见过太多人拿到数据集就开始跑代码结果发现标签是16位灰度图、图像是DICOM、类别索引对不上最后训练出来的掩膜全部是黑的。这篇文章就按我实际做医学分割项目的流程从数据解剖、格式转换、预处理、训练评估到踩坑排查完整走一遍。适合刚入门医学影像分割的研究生、算法工程师以及想用现成数据集快速验证分割方案的从业者。新手能照着步骤跑通熟手能直接抄参数和避坑清单。2. 拆解约2500张的COVID分割数据集目录结构、标签语义与类别不平衡2.1 数据集目录长什么样先看文件树再谈训练拿到数据集第一件事不是写代码是打开目录看结构。常见做法是把数据分成 images 和 masks 两个顶层目录各自又按 train、val、test 划分也有的数据集把划分信息放在一个 CSV 里。我一般会用一条 find 命令把整个树打出来。find . -maxdepth 3 -type d | sort # 输出示例 # ./images # ./images/train # ./images/val # ./images/test # ./masks # ./masks/train # ./masks/val # ./masks/test注意观察命名是否一一对应。比如 images/train/patient001_003.png 对应的掩膜应是 masks/train/patient001_003.png文件名完全一致才能用脚本批量配对。如果掩膜目录里多了几个文件或者文件名有后缀差异比如 _mask.png就要先做一次清洗和重命名。约2500张的总量划到 train、val、test 之后每个子集的样本数要心里有数。还需要确认图像和标签的位深。医学图像分割里图像常见的是 8 位 JPEG/PNG也有 16 位 PNG 甚至 DICOM 源。如果图像是 16 位灰度直接拿 skimage 读取后用 plt.imshow 显示看到的可能是一团黑因为显示端默认按 8 位映射。标签这边更关键——分割掩膜的像素值决定了类别语义。2.2 标签里每个像素值代表什么从0到255的语义陷阱分割掩膜的本质是一个和原图尺寸相同的矩阵每个像素的数值代表该位置所属的类别。最常见的是二分类语义分割背景为 0病灶区域为 255或 1也有的数据集把感染区域细分为磨玻璃影、实变等多个类别用 1、2、3 分别表示。必须先确认标签的取值分布再决定损失函数和评估指标。import numpy as np from PIL import Image mask np.array(Image.open(masks/train/patient001_003.png)) print(mask shape:, mask.shape) print(unique values:, np.unique(mask)) print(min:, mask.min(), max:, mask.max())这段代码输出掩膜的所有独立像素值。如果输出 [0, 255]就是背景加前景的二值掩膜如果输出 [0, 1, 2]就是多类别掩膜如果输出 [0, 128, 255]说明数据集用了不同的灰度表示类别需要映射归一化。这是整个项目里最容易被忽略却代价最高的一步——模型训练时如果掩膜里混着意外数值损失函数根本算不对。常见做法是写一个训练前的全局检查脚本遍历所有掩膜文件打印每张图的 unique values。这比只抽查一两张要稳妥得多尤其是在约2500张规模的数据集里人工检查不现实。2.3 类别不平衡COVID病灶占整图比例可能只有5%医学图像分割最典型的问题不是模型结构不够好而是正负样本极度不平衡。一张512x512的CT图里感染区域可能只占几百到几千个像素其余都是背景。如果用普通的 DiceLoss 或 CrossEntropy模型很容易收敛到把所有像素都预测为背景的局部最优因为这样损失已经很小了。计算一下每张掩膜里前景像素的占比会直观地看到这个数据集的病灶比例分布。通常做法是统计全部训练掩膜的前景占比取均值和中位数如果中位数低于10%就要在损失函数上做针对性设计。常用的手段包括 Dice Loss 与 Focal Loss 的组合、对少数类做加权采样、或者在预处理阶段用裁剪策略让病灶区域在训练样本中占更大比例。这些后面训练章节会具体展开。3. 把原始数据整理成可训练格式裁剪、归一化与标签重映射3.1 图像裁剪与尺寸统一别让模型被迫处理黑边CT 原图往往带着扫描床、体外空气区域等大量无关背景直接缩放到 512x512 输入模型等于让模型在无关区域上浪费参数。我一般的做法是先用掩膜信息做一个边界裁剪bounding box把肺部区域切出来再统一缩放到训练尺寸。注意关键点裁剪和缩放必须对图像和掩膜施加完全相同的几何变换绝不能分开做。import cv2 import numpy as np def crop_and_resize(image, mask, target_size(512, 512)): # 根据掩膜中前景区域计算外接矩形 ys, xs np.where(mask 0) if len(ys) 0: x_min, x_max, y_min, y_max 0, image.shape[1], 0, image.shape[0] else: x_min, x_max xs.min(), xs.max() 1 y_min, y_max ys.min(), ys.max() 1 # 略微外扩避免病灶紧贴边缘 pad 30 x_min max(0, x_min - pad) x_max min(image.shape[1], x_max pad) y_min max(0, y_min - pad) y_max min(image.shape[0], y_max pad) cropped_img image[y_min:y_max, x_min:x_max] cropped_mask mask[y_min:y_max, x_min:x_max] resized_img cv2.resize(cropped_img, target_size, interpolationcv2.INTER_LINEAR) resized_mask cv2.resize(cropped_mask, target_size, interpolationcv2.INTER_NEAREST) return resized_img, resized_mask这段代码里有两个必须说明的细节。一是interpolation的选择图像用线性插值掩膜用最近邻插值。如果掩膜也用线性插值会在类别边界产生介于 0 和 255 之间的过渡像素值导致类别语义被污染。二是空掩膜的保护如果某张训练图的掩膜里没有任何前景像素np.where返回空数组不做保护的话xs.min()会直接抛异常。这里用条件分支兜底。3.2 像素值归一化ImageNet均值不是医学影像的万能药很多从自然图像转来做医学分割的人直接套用 ImageNet 的均值和标准差做归一化。对于 CT 影像来说这不是最优选择。CT 图像的本质是人体组织对 X 射线的衰减系数单位为亨氏单位HU取值范围通常在 -1000 到 1000 之间。如果数据集提供的是原始 CT 值合理的做法是先做窗宽窗位调整把感兴趣的灰度范围映射到 [0,1]。不提供原始 HU 值也没有关系很多数据集发布时已经做了 8 位灰度映射。这种情况下直接采用数据集自身的均值和标准差做标准化或者在 [0,1] 和 [-1,1] 之间选一个固定范围。我的经验是先用一个简单的统计脚本算出训练集的均值和标准差存成 JSON训练和推理时统一调用。import numpy as np import glob files glob.glob(images/train/*.png) mean, std 0.0, 0.0 count 0 for f in files: img cv2.imread(f, cv2.IMREAD_GRAYSCALE).astype(np.float32) mean img.mean() std img.std() count 1 mean / count std / count print(train mean:, mean, std:, std)这里逐张统计均值、再取平均而不是把所有图像拼接成一个超大数组后统计。原因是约2500张图拼在一起内存压力大而且逐张统计足够稳定。保存这两个数值后面在数据加载器里用(img - mean) / std做标准化比每次重新计算要高效得多。3.3 数据集划分的纪律同一患者的切片不能同时出现在训练和验证集医学图像分割有一个天然陷阱——同一患者的相邻 CT 切片内容高度相似。如果划分数据集时只按文件随机打乱同一个患者的不同切片可能同时进训练集和验证集导致验证指标虚高模型泛化能力被严重高估。理想做法是按患者 ID 划分保证一个患者的所有切片只出现在一个集合里。约2500张的数据集如果文件名里含患者标识比如 patient001_003.png 里 patient001 就是患者 ID就需要写一个基于患者 ID 的分组划分脚本。常见做法是按患者列表做 8:1:1 的随机划分再把对应切片归入各集合。如果文件名里看不出患者 ID就需要回到数据集自带的元数据文件一般 CSV 里会有一列 patient_id。import os, random, shutil patients sorted(set([f.split(_)[0] for f in os.listdir(images)])) random.seed(42) random.shuffle(patients) n len(patients) train_patients set(patients[:int(n * 0.8)]) val_patients set(patients[int(n * 0.8):int(n * 0.9)]) test_patients set(patients[int(n * 0.9):]) for img_file in os.listdir(images): pid img_file.split(_)[0] mask_file img_file.replace(.png, _mask.png) if pid in train_patients: # move or copy to train set shutil.copy(...)注意这里的文件名后缀要对照实际数据集的命名规则改_mask.png是假定的格式。随机种子固定为 42保证每次运行同一个划分结果方便复现。如果不按患者分组模型在验证集上的 Dice 可能虚高五到十个点这是我在实际项目中真实踩过的坑。4. 用UNet在约2500张COVID数据上训练分割模型损失函数、关键参数与实现细节4.1 为什么选UNet而不是更大更深的网络数据量决定模型容量约2500张的训练数据量放到自然图像分割里算很小的规模但在医学影像领域是常态。UNet 的 U 型结构——下采样编码器提取语义上采样解码器恢复分辨率加上跳跃连接把低级特征和高层语义融合——特别适合医学图像分割因为病灶边界需要低层细节来精确定位。模型参数量在 30M 左右用单张消费级 GPU 就能训动。更深更宽的模型比如 DeepLabV3、Swin-UNet 不是不能用但在 2500 张这个量级上大模型更容易过拟合需要更强的正则化和数据增强策略来弥补数据不足。我的建议是先用 UNet 跑出一个基线如果 Dice 已经达到可接受水平再考虑换更强的骨干网络。这符合医学影像项目的一贯节奏先求稳定复现再求指标提升。4.2 损失函数组合Dice Loss 加 Focal Loss 的实践经验训练医学分割模型单用交叉熵损失会遇到类别不平衡问题。Dice Loss 直接优化 Dice 系数对前景区域占比敏感但单独使用容易在训练早期出现梯度不稳定。Focal Loss 会降低易分类样本的损失权重让模型关注难以分类的像素适合 COVID 病灶边界模糊的场景。常见方案是两个损失加权求和。import torch import torch.nn as nn import torch.nn.functional as F class DiceLoss(nn.Module): def __init__(self, smooth1.0): super().__init__() self.smooth smooth def forward(self, pred, target): pred torch.sigmoid(pred) pred pred.contiguous().view(-1) target target.contiguous().view(-1) intersection (pred * target).sum() dice (2. * intersection self.smooth) / (pred.sum() target.sum() self.smooth) return 1 - dice class ComboLoss(nn.Module): def __init__(self, dice_weight0.6, focal_weight0.4): super().__init__() self.dice DiceLoss() self.focal FocalLoss() self.dice_weight dice_weight self.focal_weight focal_weight def forward(self, pred, target): return self.dice_weight * self.dice(pred, target) \ self.focal_weight * self.focal(pred, target)Focal Loss 的实现这里省略了PyTorch 官方论坛和开源库里有大量现成版本可以直接引入。重点看DiceLoss的写法pred 先过 sigmoid 归一化到 0-1target 本身是 0 或 1 的二元张量两者逐元素相乘再求和就是这个 batch 的预测与真实前景的交集。这个实现里没有按图像维度单独计算 Dice 再求平均而是把整个 batch 展平成一个向量属于标准的全局 Dice 计算方式。参数smooth设为 1.0 是为了防止预测和真实掩膜全为零时除以零报错这是 Dice Loss 的经典稳定技巧。4.3 训练循环里的关键超参数从学习率到早停UNet 训练有一组相对成熟但必须按数据集微调的超参数。图像输入尺寸 512x512 是医学分割的常规设置batch size 在 8 到 16 之间取决于 GPU 显存初始学习率常见做法是 1e-4 到 3e-4配合 Adam 优化器。如果发现欠拟合可以降低 batch size 或调大学习率如果过拟合就要加权重衰减weight decay或数据增强。数据增强方面我常用的是随机水平翻转、随机旋转 10 度、随机亮度对比度扰动和随机弹性形变。注意旋转和弹性形变必须对图像和掩膜施加相同变换这点和前面裁剪缩放的原则一致。约2500张数据配 50 到 100 个 epoch加上增强足够让模型收敛。早停策略也值得在一开始就写好。每轮验证集 Dice 不提升就保存当前最佳权重连续 15 到 20 个 epoch 不提升就停止训练。对于医学分割项目保存最佳权重比保存最后一轮权重重要得多因为最后一轮往往已经出现过拟合。把验证集每个 epoch 的 Dice 和 Loss 记录到 CSV 或 TensorBoard训练结束后画曲线能直观看出模型在哪一轮开始泛化下降。4.4 从预测到掩膜为什么输出要过阈值而不是直接取 argmax训练完成后推理阶段同样有陷阱。UNet 输出的是一个未经过 sigmoid 的 logits 张量形状是 (B, 1, H, W)。要得到二值掩膜需要先过 sigmoid 得到 0-1 之间的概率图再和阈值比较一般取 0.5但数据集中病灶占比低时可以适当调低到 0.3 到 0.4 来召回更多前景像素。import numpy as np import torch def predict(model, img_tensor, threshold0.5): model.eval() with torch.no_grad(): logits model(img_tensor.unsqueeze(0)) prob torch.sigmoid(logits).cpu().numpy()[0, 0] mask (prob threshold).astype(np.uint8) * 255 return masklogits不是概率不能直接当掩膜用。这一步说起来简单但我见过不止一个同事拿 logits 直接做二值化得到的结果要么全是 0 要么全是 255还以为是模型没训好。阈值的选择也可以结合验证集做一次小网格搜索取验证集 Dice 最高的阈值作为最终推理参数。对于 COVID 这种病灶小而分散的情况适当降低阈值通常能提升召回率。5. 评估指标与结果可视化Dice、IoU 之外还要看边界误差和假阳性分布5.1 三个核心指标的计算方法与适用场景医学图像分割最常用的评估指标是 Dice 系数和 IoU交并比两者高度相关但语义略有差别。Dice 强调预测与真实区域的相似程度对体积重叠敏感IoU 在类别不平衡时会给出更保守的数值。第三个指标是边界 F1Boundary F1它不只看区域重叠还评估预测边界和真实边界的接近程度对 COVID 这种边界模糊的病灶特别有意义。def dice_score(pred, target, smooth1.0): pred pred.astype(bool) target target.astype(bool) intersection np.logical_and(pred, target).sum() return (2. * intersection smooth) / (pred.sum() target.sum() smooth) def iou_score(pred, target, smooth1.0): pred pred.astype(bool) target target.astype(bool) intersection np.logical_and(pred, target).sum() union np.logical_or(pred, target).sum() return (intersection smooth) / (union smooth)注意smooth在预测和真实区域均为空时的作用——如果一张验证图里根本没有病灶但模型也没有预测出任何前景Dice 和 IoU 在数学上会出现 0/0加上 smooth 后得到接近 1 的值这实际上是合理的两个空集合的重叠度可以视为 1。但如果预测为空、真实有病灶smooth 会把极小的分母变成 1让结果不至于崩溃但数值可能失真。评估时建议同时输出一张空掩膜的统计量便于判断数据集中有多少这样的样本影响了整体指标。5.2 把预测结果叠加在原图上可视化比指标更能暴露问题很多从业者只看 Dice 数字就下结论忽略了可视化检查。Dice 是一个宏观指标它告诉你预测和真实的整体重叠程度但看不出病灶边界是否偏移、小的感染区域是否被漏掉、预测是否有大片假阳性。我的习惯是从验证集随机抽 20 张图把原图、真实掩膜、预测掩膜、以及预测边界叠加图横向拼接保存成一张大图每个 epoch 结束后人眼看一遍。可视化脚本的核心逻辑是三通道叠加。原图是灰度图转成三通道 RGB 后把真实边界用绿色线条画出把预测边界用红色线条画出重叠部分显示为黄色。这种视图能快速定位模型在边界处的系统性偏移。如果发现预测的病灶区域比真实区域明显偏大且主要出现在高亮度组织附近很可能是预处理里的归一化出了问题或者训练数据里该类噪声过多。如果在验证集指标不错但某个患者的所有切片都漏检大概率是标注质量的问题——个别掩膜里病灶区域画漏了。5.3 把约2500张数据集的评估拆开看按病灶大小分组统计同样是 Dice 0.85一个模型可能在病灶面积大于 1000 像素的样本上表现极好而所有病灶小于 200 像素的样本几乎全部漏检。度量整体指标掩盖了这种性能不均衡。更合理的做法是把验证集按病灶面积分桶比如 200、200-1000、1000 三档分别统计每桶的平均 Dice 和 IoU。如果发现小病灶桶的 Dice 显著低于大病灶桶就说明模型对细节区域感知不足可以针对性地调整损失函数权重、增加小病灶切片在训练中的采样概率或者提升输入分辨率。反过来如果大病灶桶表现差更可能是编码器下采样过多导致特征图分辨率不足。这种分组统计在约2500张的数据规模下仍然有效因为验证集即便只有 250 张分三档后每档也有几十张样本统计趋势依然可信。6. 医学图像分割数据集避坑指南5个真实踩坑记录与解决方案6.1 掩膜和图像尺寸不一致训练时直接报错现象DataLoader 里img.shape是 (512, 512)mask.shape是 (512, 519)模型前向传播没报错但计算损失时两个张量无法对齐。原因数据集打包时掩膜经过了某种预处理但图像没有或者掩膜保存时带了额外边框。解决写一个数据加载前置检查断言每个批次的图像和掩膜尺寸一致。如果不一致用中心裁剪或 padding 把掩膜对齐到图像尺寸。这种问题在约2500张的公开数据集中真实存在不能假设发布方一定做对齐。6.2 掩膜像素值是 0 和 255但损失函数里 target 当作权重用现象训练 loss 震荡剧烈验证集 Dice 始终是 0。原因很多人拿到掩膜不重映射把 0 和 255 的原始值直接传入损失函数。Dice Loss 里的交叉项pred * target在有 255 参与时数值爆炸梯度方向混乱。如果使用 CrossEntropy类别数会变成 256模型根本学不会。解决在数据加载器里对掩膜做mask (mask 0).astype(np.float32)把二值掩膜统一映射到 0 和 1。全局只用这一个标准不要在某个 epoch 或某个增强函数里单独改数值。6.3 验证集 Dice 高得离谱测试集上却一塌糊涂现象验证集 Dice 0.93测试集只有 0.71差距明显。原因这是典型的数据泄露。同一患者的多张切片被随机划分到了训练集和验证集模型见过该患者大部分切片后验证时变相开卷。CT 序列中相邻切片差异极小模型不需要真正理解病灶特征只要匹配患者特征就能得分。解决回到 3.3 节按患者 ID 分组划分。如果数据集没有提供患者 ID至少把文件名前缀相同的文件视为同一患者。这个坑在医院合作项目中尤其致命因为人的体态特征比病灶特征更容易被模型学到。6.4 模型预测的掩膜全是黑色训练没报错但指标为零现象训练日志 loss 下降但可视化输出全部是 0验证集 Dice 和 IoU 都为 0。原因推理阶段忘了对 logits 做 sigmoid直接和 0.5 比较。logits 的取值范围通常在 -3 到 3 之间只有少数像素能大于 0.5因此阈值化后几乎全是背景。这是我在多个项目里见过的翻车现场不是模型问题是推理管线里少了一步激活函数。解决在 predict 函数里严格按sigmoid - threshold顺序处理。为保险起见先打印预测张量的数值范围确认有概率值分布在 0 到 1 之间再做二值化。每次修改推理脚本后先用一张已知样本自检掩膜是否为非全零。6.5 数据增强把掩膜搞出了中间值模型预测出灰蒙蒙一片现象训练时 loss 能降但预测掩膜边界区域出现大片灰色过渡带二值化后毛刺严重。原因随机旋转和缩放用了INTER_LINEAR插值处理掩膜导致原本只有 0 和 255 的掩膜出现了 127、63 之类的中间值。模型看到掩膜里有非二值的数值会学着预测出接近 0.5 的概率边界变得模糊。解决掩膜的所有几何变换必须用INTER_NEAREST这点在 3.1 节强调过但要全员统一。如果用的是 Albumentations 库要为 image 和 mask 分别指定不同的interpolation参数。固定随机种子后重训一遍确认掩膜里的值重新回到 0 和 255 两类。7. 进阶实践用训练好的UNet做全自动肺部感染区域筛查模型训好、指标达标之后真正有价值的是把它接进一个可复用的推理流程里。我的做法是封装一个完整的分割流水线输入一张原始 CT 灰度图输出病灶掩膜和叠加可视化图几秒内完成全流程。这不是一个概念性的演示而是可以直接接进项目里的核心模块。整个流水线按顺序封装成 Python 类加载模型权重、预处理裁剪、缩放、归一化、推理sigmoid 阈值、后处理形态学开闭运算去掉小孔洞和小噪点、输出掩膜和叠加图。后处理这一步容易被省略但它能显著改善掩膜质量——用scipy.ndimage的binary_opening和binary_closing过滤掉面积小于某个阈值的连通域。from scipy import ndimage def postprocess(mask, min_area50): labeled, num ndimage.label(mask) sizes ndimage.sum(mask, labeled, range(1, num 1)) remove [i 1 for i, size in enumerate(sizes) if size min_area] mask_clean mask.copy() for r in remove: mask_clean[labeled r] 0 return mask_cleanmin_area按像素数调50 对应 512x512 输入下约 0.02% 的图面积。这个参数不能固定要根据数据集的病灶尺度验证几次。如果验证集里本来就存在小于 50 像素的微小病灶把这个阈值调零或降低。最后一个习惯是保存推理时的输入输出样本和对应的概率图。概率图不只是中间产物它能帮你看清模型在哪些区域持犹豫态度。如果发现大量假阳性都出现在概率 0.4 到 0.6 的区间说明阈值选低了适当抬高阈值就可以压低假阳性率。反之如果漏检主要集中在小病灶上降阈值换来召回率也许是更优选择。用验证集做一次阈值扫描画出 Dice 随阈值变化的曲线选峰值对应的阈值做最终配置——这是我在每个分割项目最后都会做的一次实验也算是一个长期养成的习惯。医学图像分割没有一步到位的模型只有不断验证、可视化和微调的循环。希望这些能帮你把 2500 张数据和标签真正用起来少走几步弯路。本文还有配套的精品资源点击获取