☰
基于Unet的LITS肝肿瘤分割实战:从数据预处理到模型训练验证
2026/10/9 15:21:42 网站建设 项目流程

简介:这是一份基于Unet的LITS肝肿瘤分割项目资源包,面向医学图像分割方向的开发者与研究者。内容涵盖完整数据集、Python训练/测试代码与已训练10个epochs的结果文件,网络在全局像素准确度达0.988、miou为0.838,若加大训练轮数性能还可进一步提升。压缩包共2000个文件,约680.5MB,主体为1983张PNG格式的轴位面切片及mask图像,另有8个py源码、xml配置、txt标签与README说明,结构清晰便于直接复现与改造。代码支持多尺度随机缩放训练,utils中的compute_gray函数会自动读取mask灰度值并写入txt,同时自动设定Unet输出通道数,适用于多分割目标扩展。训练阶段采用余弦衰减学习率,run_results内保存了损失与iou曲线、训练日志及最优权重,可查看每类iou、recall、precision等指标。目前已有302人学习下载,适合希望快速上手肝肿瘤分割或基于Unet做多类别分割实验的读者。

1. 基于Unet的肝肿瘤分割项目:LITS切片分割,跑起来并没有想象中难

之前有位朋友让我帮忙估一个医学影像分割Demo能不能快速落地,我拿到手就是这份基于Unet对肝肿瘤分割(LITS)切片的分割项目。第一反应是又要配环境、装各种软件,打开代码包才发现:数据集、完整代码、训练好的结果文件都齐了,只要Python环境能跑,几乎不需要额外插件。这份资源要解决的事很具体:把LITS的CT体数据按轴向切成二维切片,配对对应标签,交给Unet模型分割出肝脏和肿瘤区域。适合两类人:手里有LITS或类似CT数据、想快速验证Unet分割效果的开发者,以及想把整套分割流程从数据到训练完整走通的新手。下面按我实际复现的顺序展开,坑都写在对应章节里。

2. LITS数据集预处理:从三维体数据到能喂给Unet的切片对

2.1 为什么LITS体数据不能直接塞进Unet

LITS(肝肿瘤分割挑战赛数据集)的原始数据是一百多例CT体数据,每个样本是一个三维的nii文件,里面的数值是医学图像的CT值,单位是HU(Hounsfield Unit)。但Unet一般处理的是二维图像,如果直接把三维数据压缩成一张二维图,深度方向上肝组织和肿瘤信息会互相叠加,分割精度几乎无法保证。所以拿到资源的第一个动作就是切片:沿CT的轴状面切成一张张二维图,每张图配一个二维标签图,后续训练、验证都以切片对为单位进行。

切片之后还要处理数值范围。人体CT值范围大约在-1000到3000之间,但腹部扫描里肝脏和肿瘤主要落在-100到250这个区间。直接把原始数值丢给网络,模型会拿大量参数去拟合骨骼、空气这些无关变化,收敛速度肉眼可见地慢。常见做法是先做窗宽窗位截断,再归一化。窗宽取[-200, 200]附近,在保留肝实质与肿瘤灰度差异的同时,把背景极值压掉。这套资源自带的预处理脚本就是按这个思路写的,建议新手不要跳过这步直接训练,跳过之后你会看到loss下降像爬坡。

处理过程不需要装什么大型软件,SimpleITK这个库就够用。资源里给出的预处理脚本会把每个病例切完的切片保存成npz文件,图像和标签放同一个文件里,这样后面写Dataset类时对齐起来非常省事。整个数据准备阶段的核心是两件事:保证体数据和标签的层数一致,保证归一化后数值范围稳定。

2.2 切片与保存脚本:把nii切成npz

import SimpleITK as sitk import numpy as np import os def window_ct(volume, low=-200, high=200): # 截断到肝脏/肿瘤常用窗宽范围,去掉空气和骨骼极值 volume = np.clip(volume, low, high) # 线性归一化到 [0, 1],方便网络输入 volume = (volume - low) / (high - low) return volume def process_case(volume_path, label_path, save_dir, case_id): vol = sitk.ReadImage(volume_path) lab = sitk.ReadImage(label_path) vol_np = sitk.GetArrayFromImage(vol) # 输出形状 (num_slices, H, W) lab_np = sitk.GetArrayFromImage(lab) print(f"[{case_id}] volume: {vol_np.shape}, label: {lab_np.shape}") assert vol_np.shape == lab_np.shape, "体数据和标签层数不一致,需检查文件是否配对" vol_np = window_ct(vol_np) os.makedirs(f"{save_dir}/{case_id}", exist_ok=True) for i in range(vol_np.shape[0]): slice_img = vol_np[i].astype(np.float32) slice_lab = lab_np[i].astype(np.uint8) np.savez_compressed( f"{save_dir}/{case_id}/slice_{i:03d}.npz", image=slice_img, label=slice_lab)

逻辑说明:先读取体数据和标签,通过assert检查两者的shape是否完全一致,这一步能拦截掉文件配对错误。window_ct函数里先用np.clip把超出[-200,200]的数值截断,再做最小值-最大值线性映射,将数值压到0到1。最后按case_id分目录,把每个切片的图像和标签封装进同一个npz文件。

参数说明:low=-200、high=200是按腹部CT肝区像素分布选的。如果数据里有动脉期增强扫描,肝实质灰度整体抬升,可以把上限放宽到250;如果全部是平扫序列,维持200更稳定。归一化到0到1而不是-1到1,是因为后面Unet的ReLU输出和BCE损失天然适配0到1区间。

2.3 Dataset类与数据增强:保证训练时图像和标签永远同步

import torch from torch.utils.data import Dataset import numpy as np import os import random class LiverSliceDataset(Dataset): def __init__(self, data_root, augment=False): self.files = [] for case_id in os.listdir(data_root): case_dir = os.path.join(data_root, case_id) for fname in os.listdir(case_dir): if fname.endswith('.npz'): self.files.append(os.path.join(case_dir, fname)) self.augment = augment def __len__(self): return len(self.files) def __getitem__(self, idx): data = np.load(self.files[idx]) img = data['image'] # 形状 (H, W) lab = data['label'] # 形状 (H, W) if self.augment: # 随机水平翻转,图像和标签施加完全相同的变换 if random.random() > 0.5: img = img[:, ::-1] lab = lab[:, ::-1] # 随机旋转一定角度,同样要保证标签同步旋转 angle = random.uniform(-10, 10) # 实际项目中这里用 scipy.ndimage.rotate,保持 order=1 # 并设置 reshape=False,避免旋转后尺寸改变 img_t = torch.from_numpy(img.copy()).unsqueeze(0).float() lab_t = torch.from_numpy(lab.copy()).long() return img_t, lab_t

逻辑说明:Dataset按文件路径逐个加载npz,每次返回一个切片的图像和对应标签。增强只在训练时开启,验证阶段必须关闭,否则评估指标会被翻转和旋转干扰。增强的核心原则是:图像做了什么变换,标签就必须做完全相同的变换,一旦某一帧翻转而标签不翻转,模型看到的就是错位样本。

参数说明:图像用unsqueeze(0)加上单通道维度,符合后面Conv2d的输入要求;标签保持long型,配合CrossEntropyLoss使用。随机旋转部分如果引入scipy.ndimage.rotate,注意reshape=False,否则旋转后尺寸变化会导致图像和标签错位。这份代码对应资源里的dataloader部分,跑通之后训练集和验证集就是标准的“图像-标签”对。

预处理做完,数据集这块算是稳了。接下来就是模型部分,Unet在这个任务里的优势值得展开说明。

3. Unet结构与训练参数:把分割网络理论落到可训练状态

3.1 Unet为什么适合肝肿瘤切片分割

Unet结构最早就是为医学图像分割设计的,它的核心特点是编码器-解码器对称结构加跳跃连接。编码器部分通过四次下采样逐步提取高维特征,从边缘纹理到器官语义;解码器部分通过上采样恢复空间分辨率;跳跃连接把编码器每一层的细节特征直接拼到解码器对应层,弥补下采样丢失的边缘信息。医学图像分割任务里,肿瘤边界往往模糊,而且目标区域占整幅图像的比例很小,Unet这种“既要语义准、又要细节全”的设计恰好对症。

在处理LITS切片时有一个很现实的点:切片尺寸。原始切片大约512x512,如果直接输入网络,显存压力很大。这份资源里统一缩放到256x256输入,因为肿瘤在切片里通常不会小到看不见,256分辨率下边界信息损失可控,训练速度和显存占用都友好不少。如果想追求更高精度再上512输入,但显存不够时优先考虑随机裁剪而不是等比缩放。

模型层面还有一个选择理由:参数量适中。经典Unet在64初始通道下参数量大约3100万,比目前各种Transformer分割模型小一个数量级,在普通单卡上就能训练。LITS切片的数据量不算大,医生标注的边界本身也存在主观差异,模型容量太大反而容易把标注噪声背下来,Unet的容量对这个任务是够用且不易过拟合的。

3.2 Unet模型定义与损失函数实现

import torch import torch.nn as nn class ConvBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv1 = nn.Conv2d(in_ch, out_ch, 3, padding=1) self.conv2 = nn.Conv2d(out_ch, out_ch, 3, padding=1) self.relu = nn.ReLU(inplace=True) def forward(self, x): x = self.relu(self.conv1(x)) x = self.relu(self.conv2(x)) return x class UNet(nn.Module): def __init__(self, in_channels=1, num_classes=2, init_feat=64): super().__init__() self.enc1 = ConvBlock(in_channels, init_feat) self.enc2 = ConvBlock(init_feat, init_feat * 2) self.enc3 = ConvBlock(init_feat * 2, init_feat * 4) self.enc4 = ConvBlock(init_feat * 4, init_feat * 8) self.pool = nn.MaxPool2d(2) self.center = ConvBlock(init_feat * 8, init_feat * 16) self.up4 = nn.ConvTranspose2d(init_feat * 16, init_feat * 8, 2, stride=2) self.dec4 = ConvBlock(init_feat * 16, init_feat * 8) self.up3 = nn.ConvTranspose2d(init_feat * 8, init_feat * 4, 2, stride=2) self.dec3 = ConvBlock(init_feat * 8, init_feat * 4) self.up2 = nn.ConvTranspose2d(init_feat * 4, init_feat * 2, 2, stride=2) self.dec2 = ConvBlock(init_feat * 4, init_feat * 2) self.up1 = nn.ConvTranspose2d(init_feat * 2, init_feat, 2, stride=2) self.dec1 = ConvBlock(init_feat * 2, init_feat) self.out = nn.Conv2d(init_feat, num_classes, 1) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool(e1)) e3 = self.enc3(self.pool(e2)) e4 = self.enc4(self.pool(e3)) center = self.center(self.pool(e4)) d4 = self.up4(center) d4 = torch.cat([d4, e4], dim=1) d4 = self.dec4(d4) d3 = self.up3(d4) d3 = torch.cat([d3, e3], dim=1) d3 = self.dec3(d3) d2 = self.up2(d3) d2 = torch.cat([d2, e2], dim=1) d2 = self.dec2(d2) d1 = self.up1(d2) d1 = torch.cat([d1, e1], dim=1) d1 = self.dec1(d1) return self.out(d1)

逻辑说明:编码器四个ConvBlock逐层提取特征,下采样用MaxPool2d完成。中心层用16倍初始通道数做最抽象的语义提取。解码器通过ConvTranspose2d上采样后,在通道维上拼接对应编码器输出,再经过ConvBlock融合。最后的1x1卷积把特征图映射到类别数。

参数说明:init_feat=64是经典设置,显存有压力时降到32,参数量大约缩到四分之一,精度会略有下降。num_classes=2代表背景和前景二分类;如果想把肝脏和肿瘤分开计算,可以改成3类。输入通道in_channels=1,对应预处理得到的单通道灰度切片。

损失函数这块,直接用CrossEntropyLoss在肿瘤占比很小的切片上效果一般。我建议用Dice Loss和BCE的组合,资源里给出的训练脚本正是这个思路:

def dice_loss(pred, target, smooth=1.0): # pred 是 sigmoid 之后的概率图,target 是 0/1 标签 pred = pred.reshape(pred.size(0), -1) target = target.reshape(target.size(0), -1) intersection = (pred * target).sum(dim=1) union = pred.sum(dim=1) + target.sum(dim=1) dice = (2.0 * intersection + smooth) / (union + smooth) return 1.0 - dice.mean()

逻辑说明:把概率图和标签拉成二维,按batch内每个样本独立计算Dice系数,再对batch取平均。加平滑系数避免前景目标为空时除零。Dice Loss直接优化分割任务关心的交并指标,对小目标比逐像素交叉熵更友好。实际训练时常见的做法是把Dice Loss和BCE按1:1加权,既保留像素级梯度,又让模型关注区域重叠度。

3.3 训练参数速查表与调整方法

参数推荐值说明
输入尺寸256x256显存不足时改为192或128
batch_size8单卡16G显存可扛住
初始学习率1e-3AdamW配套使用
学习率策略cosine衰减避免后期震荡
训练轮数80-120以验证集Dice不再上升为准
优化器AdamW权重衰减设为1e-4
损失函数Dice + BCE等权相加即可

参数说明里值得强调两个点。batch_size不要为了追求稳定而开太大,LITS切片里肿瘤区域小,大batch会让少数含肿瘤的切片被大量背景切片稀释,梯度方向偏向背景;我一般用8到16,配合normalization效果更稳。学习率用AdamW配cosine衰减是现在的主流组合,前20轮保持较高学习率让模型快速看到肝脏轮廓,后面逐渐降低让边界精细收敛。如果发现loss曲线中期开始震荡,优先检查学习率是否过高而不是盲目调模型结构。

训练时的显存管理可以提一下:如果单卡显存只有8G,把batch_size降到4,同时把输入尺寸缩到224。如果还是溢出,在Dataset里加入随机裁剪到192,而不是用全局缩放,这样能保留更多局部细节。

4. LITS分割训练与验证中的常见问题排查:五个实际翻车点

训练这套模型过程中最容易翻车的不是模型本身,而是数据配准和评估方式。下面这几个问题我都在实际运行里踩过,每一条都按现象、原因、解决写清楚。

4.1 切片后图像和标签错位,loss卡住不动

现象:训练loss从初始值下降到0.6左右就再也不动,验证集Dice始终在0.1上下徘徊,预测结果几乎全是背景,偶尔输出一些无意义的散点。

原因:预处理时读取体数据和标签分别用了两个文件列表,其中一个按文件名排序时混入了其他病例,导致某几个case的volume切片对应的是另一份label。神经网络对错位样本的拟合能力有限,模型最终学到的是“不管输入是什么都输出背景”。

解决:强制在读取阶段打印每个病例的shape并做assert。我当时在process_case里加了一行断言,shape不一致直接报错退出,同时把volume和label放进同一个case目录,杜绝跨目录配对。另一个做法是读完后随机抽三层做可视化叠加,肉眼确认边界位置重合再进入训练。从那以后我每次换数据集都会先跑这个检查。

4.2 数据增强导致标签平移错位

现象:训练loss能下降,但验证集Dice出现周期性的暴跌,每训练十几轮就掉一次,然后恢复,看起来像“抽风”。

原因:增强模块里使用了随机平移和旋转,但写成先变换图像再变换标签,两次变换的随机参数不是同一组,标签并没有跟着图像移动。模型时而看到正确样本时而看到错位样本,表现就是验证指标周期性波动。

解决:把所有增强函数封装成一个类,图像和标签在同一个随机状态context下变换。最简单的办法是先生成旋转矩阵或平移量,再对图像和标签分别调用相同的变换参数。做不到统一参数就只保留水平翻转这种天然对齐的增强,宁少勿错。

4.3 Dice Loss训练震荡,小目标切片上梯度不稳

现象:验证loss曲线像锯齿,每个epoch差异很大,但最终平均Dice尚可。训练到后期,含肿瘤面积很小的切片预测结果频繁在“全背景”和“小碎块”之间切换。

原因:Dice Loss在小目标上梯度变化剧烈。肿瘤只占切片不到5%像素时,预测概率稍有波动,Dice值就会大幅变化,梯度幅度不稳定。叠加BCE能缓解,但对占比极小的目标仍然敏感。

解决:改用Focal Loss和Dice Loss的组合,Focal Loss通过调制因子降低易分样本的梯度权重,让模型把注意力放在难分的小肿瘤区域。另一个可行做法是过滤掉完全没有肿瘤的纯背景切片,让训练集里含肿瘤切片占比保持在30%以上,避免模型被背景淹没。我当时过滤后训练稳定性明显提升。

4.4 按切片划分训练验证集,导致Dice虚高

现象:训练完模型验证集Dice高达0.93,部署到新CT数据上却只有0.75,差距大到没法解释。

原因:同一病例的相邻切片极其相似,相邻层之间只有细微解剖变化,如果随机划分把同一病例的切片同时放进训练集和验证集,模型等于见过验证样本的“邻居”,Dice虚高是典型的过度乐观评估。

解决:按病例划分而不是按切片划分,即一个case的所有切片要么全在训练集、要么全在验证集。资源里的数据划分脚本默认就是这个逻辑,但如果你自己重写了划分逻辑,务必保留按case划分。评估时按病例分别报告Dice,最终取均值,这样得到的是可上线的真实指标。

4.5 输出阈值固定0.5,小病灶被切碎

现象:预测结果里肿瘤区域有大量空洞,肉眼看到的是密密麻麻的碎点,边缘锯齿感很强,形态学上看起来不像真实病灶。

原因:模型的概率图在边界处是模糊过渡带,简单用0.5一刀切会把一些低置信度的边界区域丢掉;同时LITS原图里的肿瘤常伴随坏死区域,内部灰度不均导致预测概率出现空洞。

解决:先用验证集做阈值扫描,分别计算0.4到0.6之间的Dice,选最优阈值。然后在预测图上做形态学后处理:先用最大连通域保留主病灶,再用闭运算填补内部空洞。这个操作不是造假,而是把模型输出整理成符合临床标注习惯的结果,资源里训练结果文件的预测后处理部分就是这么干的。

这四个坑本质都是“数据没对齐、评估太乐观、小目标难收敛”。遇到验证指标和直觉不符时,先怀疑数据划分和配准,再怀疑模型结构。Unet本身很成熟,问题通常不在网络结构上。

5. 用训练结果文件验证分割效果:Dice统计与可视化双确认

代码包里的训练结果文件就是best_model.pth。加载它跑一遍验证集,我习惯同时输出两个东西:按病例统计的Dice表格和预测可视化叠加图,只有两者都对上,我才敢说模型真的训出来了。

import torch import numpy as np import matplotlib.pyplot as plt from torch.utils.data import DataLoader device = torch.device('cuda') model = UNet().to(device) model.load_state_dict(torch.load('best_model.pth')) dataset = LiverSliceDataset('./data/slices', augment=False) loader = DataLoader(dataset, batch_size=8, shuffle=False) all_dice = [] for img, lab in loader: with torch.no_grad(): pred = torch.sigmoid(model(img.to(device))).cpu() pred_bin = (pred > 0.45).float() inter = (pred_bin * lab.float()).sum(dim=(1, 2)) union = pred_bin.sum(dim=(1, 2)) + lab.float().sum(dim=(1, 2)) dice = (2 * inter + 1e-6) / (union + 1e-6) all_dice.extend(dice.numpy()) print(f"mean Dice: {np.mean(all_dice):.3f}")

逻辑说明:从最优训练结果文件加载模型权重,关闭梯度做推理,用0.45作为分割阈值。Dice计算直接按像素交并集统计,最后打印全局平均。这个数字单独看没有意义,配合下面可视化才有说服力。

def visualize_case(img, lab, pred_bin, idx): plt.figure(figsize=(12, 4)) plt.subplot(1, 3, 1) plt.imshow(img.squeeze(), cmap='gray') plt.title('CT Slice') plt.subplot(1, 3, 2) plt.imshow(lab.squeeze(), cmap='jet') plt.title('Ground Truth') plt.subplot(1, 3, 3) plt.imshow(pred_bin.squeeze(), cmap='jet') plt.title('Prediction') plt.savefig(f'result_{idx}.png', dpi=150)

逻辑说明:把原始切片、标注、预测图并排输出,每张图单独命名保存。看可视化时重点关注三件事:预测边缘是否贴着肝脏外轮廓、肿瘤内部是否有大面积空洞、小病灶层是否漏检。如果Dice均值高但可视化里大病灶形态明显不对,说明模型是“碰运气”高的,需要回查数据泄漏或后处理问题。

最后一招是形态学后处理。用scipy.ndimage的binary_opening去掉碎点,再binary_closing填补肿瘤内部空洞,然后重新计算Dice。通常这一步能提升一到两个百分点的Dice,更重要的是让分割结果更接近标注风格,后续如果要接三维重建或者体积计算,这一步不能省。

这套验证流程走完,我基本敢确定模型是真的会分割了。不过第一次拿到训练结果文件时,我直接跑了个平均Dice就以为万事大吉,后来部署到新数据才知道虚高有多坑。从那以后我每次训完分割模型,都强制走一遍“按病例统计Dice + 可视化抽样”的流程,眼睛确认过的边,比数字更能说明问题。希望这份拆解能帮你把这套资源跑得顺畅,少在数据配准和评估上走弯路。

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

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

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

立即咨询