ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用GAN做3D肝脏分割:Jupyter Notebook端到端训练实践

用GAN做3D肝脏分割:Jupyter Notebook端到端训练实践 简介这一压缩包提供了基于生成对抗网络的3D肝脏分割Python实现面向医学图像处理与深度学习方向的开发者可用于CT影像中肝脏区域的自动识别与边界划分。资源共12个文件以Python脚本、Jupyter Notebook和Markdown文档为主涵盖数据获取、模型训练、预测及README说明压缩包整体仅529KB。已有108人学习下载适合中等以上水平的读者快速上手。通过该项目可掌握GAN生成器与判别器的对抗训练流程、3D卷积网络设计、Dice相似系数等评估方法以及如何在Jupyter环境中可视化分割结果同时附带的Shell脚本与配置文件也便于在本地或HPC集群上复现实验。1. 用GAN做3D肝脏分割为什么值得在Jupyter Notebook里跑通一次CT序列本质是几十上百张2D切片堆叠成的3D体数据肝脏分割要解决的是把属于肝脏的体素逐个标出来。传统阈值和区域生长在肝脏与周围组织对比度低、灰度不均的切片上很容易翻车纯监督的U-Net在小数据集上又缺乏对纹理和边界的约束。GAN做3D肝脏分割核心思路是在分割网络后面挂一个判别器让生成器不只是逼近标签还要骗过判别器把边界纹理和小结构修得更像专家标注。这个方案特别适合想在Jupyter Notebook里跑通端到端训练、手头有NIfTI格式CT数据的人。下面按模型结构、训练代码、常见坑和验证方法逐层展开。2. 3D肝脏分割里的GAN在学什么从对抗博弈到Dice损失的配合2.1 监督分割为什么还要GAN判别器补上的正是边界纹理普通分割网络U-Net、VNet用的都是有监督损失最常见的是Dice损失和交叉熵。这类损失衡量的是预测和标签在体素级别的重叠程度它对整体重叠敏感但对局部纹理、边界连续性几乎没有约束。训练后期会出现一种很典型的现象Dice已经到0.9以上切片放大看边缘有一圈毛刺或者小血管处断成两截。这些问题在临床测体积时误差不大但拿去做术前规划医生会直接质疑分割结果能不能用。GAN的引入方式并不复杂。保留原分割网络作为生成器G输入CT patch输出分割概率图另外加一个判别器DD的输入是真实标签图或生成器输出的分割图输出这个图是真标签还是假预测。G不仅要让Dice损失小还要让D的输出无法区分真假那就得把D能识别的纹理差异也补上。这和GAN图像修复里判别器判断修复区域是否自然是同一套博弈只是把修复区域换成了分割结果底层逻辑完全一致。反直觉的一点是单纯跑U-NetDice可能已经不错加上判别器Dice不一定涨多少但HD9595%豪斯多夫距离通常会明显下降。原因是Dice衡量重叠HD95衡量表面距离边缘毛刺对Dice影响小、对HD95影响大。判别器恰恰会惩罚边缘毛刺这类结构上不真实的产物所以GAN方案在需要精确边界的手术规划场景里更有实际价值。2.2 数据准备NIfTI读入、CT窗口裁剪与ROI粗分割3D分割最先卡住的往往不是模型而是数据。医学CT一般以NIfTI或DICOM形式存在NIfTI一个文件就是一个完整3D体数据。第一件要做的事是把原始CT值裁剪到肝脏增强CT的窗宽窗位范围。肝脏在增强CT里大约在-80到180HU之间我一般把下界设为-150、上界设为250超出部分截断再线性归一化到0到1。不做这一步模型会把肋骨、脾脏和背景噪声都当成同等重要的输入训练会慢很多。2D图像堆叠为3D图像在这里的体现是每个病例的CT本来就是数百张2D切片按层间距堆叠起来的读入后就是一个三维数组真正要处理的是体素的各向异性——x、y方向的像素间距和z方向的层间距经常不一致。这个不统一会直接影响3D卷积的感受野所以常见做法是先重采样到各向同性比如统一到1.0×1.0×1.0mm。重采样后每个病例的尺寸大约在512×512×300到512×512×400之间直接整图喂给3D网络任何消费级显卡都会OOM。所以需要做ROI裁剪。先用宽松阈值把体型区域提取出来或者直接用标签的包围盒训练集有标签的话把每个病例的肝脏区域连同一定上下文边距切出来。一般沿z轴看标签覆盖范围前后各扩20个切片x、y方向按标签包围盒扩30个像素。这一步能把单个体数据从512×512×300缩到256×256×120左右计算量直接降一个数量级。import nibabel as nib import numpy as np from scipy import ndimagedef load_and_preprocess(nii_path, lower-150, upper250): img nib.load(nii_path) data img.get_fdata(dtypenp.float32) data np.clip(data, lower, upper) # 1. CT值截断到肝脏窗 data (data - lower) / (upper - lower) # 2. 线性归一化到 [0,1]# 3. 用阈值粗提取体型区域减小后续计算范围 body_mask data 0.05 slices ndimage.find_objects(body_mask)[0] data data[slices] # 按体型区域裁剪 return data, img.affine, slices这段代码里需要注意两点。第一np.clip的两个阈值不是随便给的-150到250HU基本覆盖门静脉期肝脏实质和肿瘤的增强范围改到平扫期相需要把窗重新拉宽第二ndimage.find_objects返回的切片元组要保存下来推理时把预测结果映射回原始坐标系要反向做一次填充。如果只是做训练实验这个映射关系可以先不保存但做完整项目时迟早要补上。关于重采样项目包里有SimpleITK的话我更倾向于用它而不是nibabel因为Resample是现成的。关键参数是spacing和interpolator把目标spacing设为1.0、1.0、1.0图像插值用线性标签重采样用最近邻避免插值污染类别边界。import SimpleITK as sitkdef resample_to_iso(itk_img, target_spacing(1.0, 1.0, 1.0), is_labelFalse): original_spacing itk_img.GetSpacing() original_size itk_img.GetSize() new_size [ int(round(original_size[0] * original_spacing[0] / target_spacing[0])), int(round(original_size[1] * original_spacing[1] / target_spacing[1])), int(round(original_size[2] * original_spacing[2] / target_spacing[2])), ] resampler sitk.ResampleImageFilter() resampler.SetOutputSpacing(target_spacing) resampler.SetSize(new_size) resampler.SetOutputOrigin(itk_img.GetOrigin()) resampler.SetOutputDirection(itk_img.GetDirection()) resampler.SetInterpolator( sitk.sitkNearestNeighbor if is_label else sitk.sitkLinear ) return resampler.Execute(itk_img)new_size是按原尺寸×原spacing除以目标spacing算出来的不能直接写死因为不同病例的层间距差别很大。标签图重采样务必用最近邻线性插值会把0和1模糊成0.4、0.7后面算损失和评估都会出问题这是新手最容易忽略的边界条件。2.3 预处理管线怎么组织Jupyter里把中间产物落盘拿到下载的zip包解压后一般会看到data目录原始nii和标签、processed目录、weights目录和主训练notebook。预处理不要放在训练notebook里现做现用。CT重采样加ROI裁剪单病例在普通CPU上要跑半分钟到一分钟30个病例就是半小时如果每次开notebook都从头跑一遍调参的耐心会被消磨光。我一般的做法是写一个独立预处理脚本跑完后把裁剪好的数据和标签按npz格式写到processed目录训练时只读落盘文件。注意Jupyter Notebook默认保存路径不等于notebook所在目录。如果你用相对路径data/xxx.nii读取失败先执行一行os.getcwd()确认kernel工作目录再把路径改成基于notebook所在目录的绝对路径。这个默认保存路径的坑我见过不止一个人卡在第一步。代码层面全篇用PyTorch原因很实际3D卷积、InstanceNorm、AMP混合精度和checkpoint接口都成熟医学图像社区默认生态就是PyTorch。环境上python3.8以上装nibabel、simpleitk、torch、scipy就够。预处理脚本核心就是for循环对每个病例调用上面两个函数结果用np.savez_compressed保存内容包括image、label和case_name。image和label都用float32保存频繁读取时省去类型转换的麻烦虽然float16能省一半内存但进模型前还要转回来多一道转换反而容易出错。3. 搭建GAN分割模型3D U-Net生成器与PatchGAN判别器的选型3.1 生成器3D U-Net做骨干显存与感受野的平衡生成器直接决定分割质量结构上我基本沿用3D U-Net的编码-解码骨架这本质上就是一个3D卷积自编码器中间加跳跃连接保留高分辨率细节。选择它而不是直接上更深的网络原因很实际3D卷积的显存开销是立方的网络每加深一层、feature map边长减半通道数才能翻倍再深下去感受野收益递减显存却扛不住。对肝脏这种边界相对完整的器官四层下采样足够了。下面给出一个精简但能跑的生成器定义用PyTorch写核心是Conv3d加InstanceNorm3d加LeakyReLU组合下采样用stride2卷积上采样用转置卷积加跳跃连接。import torch import torch.nn as nndef conv_block(in_c, out_c): return nn.Sequential( nn.Conv3d(in_c, out_c, kernel_size3, padding1), nn.InstanceNorm3d(out_c), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv3d(out_c, out_c, kernel_size3, padding1), nn.InstanceNorm3d(out_c), nn.LeakyReLU(0.2, inplaceTrue), )class Generator3DUNet(nn.Module): definit(self, in_ch1, base_ch32): super().init() self.enc1 conv_block(in_ch, base_ch) # 1 - 32 self.pool1 nn.Conv3d(base_ch, base_ch, 3, 2, 1) self.enc2 conv_block(base_ch, base_ch2) # 32 - 64 self.pool2 nn.Conv3d(base_ch2, base_ch2, 3, 2, 1) self.enc3 conv_block(base_ch2, base_ch4) # 64 - 128 self.pool3 nn.Conv3d(base_ch4, base_ch4, 3, 2, 1) self.bottleneck conv_block(base_ch4, base_ch*8) # 256self.up3 nn.ConvTranspose3d(base_ch*8, base_ch*4, 2, 2) self.dec3 conv_block(base_ch*8, base_ch*4) self.up2 nn.ConvTranspose3d(base_ch*4, base_ch*2, 2, 2) self.dec2 conv_block(base_ch*4, base_ch*2) self.up1 nn.ConvTranspose3d(base_ch*2, base_ch, 2, 2) self.dec1 conv_block(base_ch*2, base_ch) self.out nn.Conv3d(base_ch, 1, 1) def forward(self, x): e1 self.enc1(x); p1 self.pool1(e1) e2 self.enc2(p1); p2 self.pool2(e2) e3 self.enc3(p2); p3 self.pool3(e3) b self.bottleneck(p3) d3 self.up3(b); d3 self.dec3(torch.cat([d3, e3], dim1)) d2 self.up2(d3); d2 self.dec2(torch.cat([d2, e2], dim1)) d1 self.up1(d2); d1 self.dec1(torch.cat([d1, e1], dim1)) return self.out(d1)几个参数需要特别说明。base_ch从32起步4GB显存可以跑8GB以上的卡可以提到48或64Dice通常会小幅上升但显存压力直线增加。Encoder里不用池化而是用stride2卷积做下采样目的是让网络自己学下采样特征而不是固定丢失信息。InstanceNorm而不是BatchNorm是关键决策3D分割的batch size通常只有1或2BatchNorm在一个小batch里算统计量极度不稳定InstanceNorm只看单样本自身在这个场景明显更稳。最后输出层用1×1卷积把通道压到1输出logits后面接sigmoid得到概率图。3.2 判别器PatchGAN与损失函数怎么配判别器结构比生成器简单常见做法是PatchGAN每个输出单元对应输入的一个局部patch而不是对整个图像给一个真假分数。patch级判断对分割图的局部纹理更敏感更适合和Dice损失配合因为Dice管整体重叠PatchGAN管局部真实性。判别器内部同样用Conv3d加LeakyReLU逐步下采样最后输出多响应点的score map。class PatchDiscriminator3D(nn.Module): definit(self, in_ch2, base_ch32): super().init() self.conv nn.Sequential( nn.Conv3d(in_ch, base_ch, 4, 2, 1), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv3d(base_ch, base_ch2, 4, 2, 1), nn.InstanceNorm3d(base_ch2), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv3d(base_ch2, base_ch4, 4, 2, 1), nn.InstanceNorm3d(base_ch4), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv3d(base_ch4, 1, 4, 1, 0), # 输出score map )def forward(self, x): return self.conv(x)判别器输入是2通道第一通道是CT patch第二通道是标签图或生成器的预测概率图。这样设计的原因判别器不只看分割图本身还要结合CT原始纹理来判断分割边界是否落在真实组织边界上。只给分割图的话判别器会退化成看形状像不像的检测器对位置错位的预测毫无压力。损失函数是GAN方案的核心推荐组合是LSGAN对抗损失加Dice损失。LSGAN用最小二乘形式判别器对真标签输出接近1、对假预测输出接近0相比传统二分类交叉熵梯度更平滑训练也更稳定这是GAN图像修复里大量验证过的选择。生成器总损失为 L_G L_dice λ·L_adv其中L_dice是预测概率图和标签的Dice损失L_adv是生成器试图骗过判别器的对抗损失。λ经验值是0.01到0.1从0.05起步。λ过大会让生成器过度追求看起来真实而忽略分割准确度出现过平滑过小则GAN部分起不到作用。3.3 超参设置patch size、batch size、学习率的参考值下面是跑这类任务比较稳的起点配置先按这个跑通再调参数参考值调参方向patch size128×128×48显存不够先减z轴到32不要动x/ybatch size1显存允许可到2但不建议大生成器学习率1e-4训练不稳时降为5e-5判别器学习率1e-4判别器过快时单独降至5e-5对抗损失权重λ0.05边缘过平滑调小纹理粗噪调大训练轮数100–200早停关注验证HD95优化器Adam(beta10.5, beta20.999)beta10.5是GAN常用值patch size是第一个要锁定的参数。128×128×48在12GB显存下batch size1可以跑8GB卡就把z轴降到32。注意不要等比缩小三个方向x/y方向的感受野对边界纹理影响大z方向层间分辨率本来就低降z对效果影响相对小。判别器学习率一般和生成器持平或略低如果发现判别器损失快速下降而生成器损失震荡先把判别器学习率降一半。GAN训练里判别器和生成器的平衡多少带点玄学不要指望一组参数通吃所有数据集。4. 在Jupyter里跑通端到端训练补丁采样、训练循环与推理后处理4.1 补丁采样肝脏占比过滤与前景偏置预处理后一个病例仍然是256×256×120的体量整图进模型不现实训练要用patch采样。朴素的随机采样会采到大量空patch——肝脏在腹部CT里只占很小一部分随机位置95%以上是背景训练效率极低。常见做法是前景偏置采样以70%概率从标签前景体素附近采样用随机偏移保证窗口覆盖肝脏边界其余30%在全图随机采样保留对背景的泛化能力。def sample_patch(image, label, patch_size(128, 128, 48), fg_prob0.7): d, h, w patch_size z_max, y_max, x_max image.shapeif np.random.rand() fg_prob and (label 0).any(): # 从前景体素附近采样 fg_z, fg_y, fg_x np.where(label 0) idx np.random.randint(len(fg_z)) cz, cy, cx fg_z[idx], fg_y[idx], fg_x[idx] else: # 全图随机采样 cz np.random.randint(d//2, z_max - d//2) if z_max d else z_max//2 cy np.random.randint(h//2, y_max - h//2) if y_max h else y_max//2 cx np.random.randint(w//2, x_max - w//2) if x_max w else x_max//2 z0 min(max(cz - d//2, 0), z_max - d) y0 min(max(cy - h//2, 0), y_max - h) x0 min(max(cx - w//2, 0), x_max - w) img_patch image[z0:z0d, y0:y0h, x0:x0w] lbl_patch label[z0:z0d, y0:y0h, x0:x0w] return img_patch, lbl_patch这个采样函数有几个参数值得注意。fg_prob取0.7太高会让模型只见过前景附近的内容推理时对远离肝脏的区域乱输出太低又回到全随机采样的低效率。如果训练时连续出现空patch先在采样函数里打印每个patch的肝脏体素占比来确认。另外可以加z轴翻转、y轴翻转的随机增强医学影像不建议做x轴翻转因为左右位置在解剖上不对称。提示如果训练前30轮Dice一直低于0.5优先检查patch里是否有足够的肝脏体素而不是调学习率。4.2 训练循环判别器与生成器的更新顺序、损失记录GAN的训练循环比普通分割多一个关键步骤判别器每轮要交替看真样本和假样本再更新生成器。常见做法是每个iteration先更新判别器再更新生成器各更新一次。如果判别器太弱生成器会失去对抗压力太强生成器梯度消失。下面这个循环里加入了对dice和adv_loss的分开记录方便观察两者是否失衡。from torch.utils.data import Dataset, DataLoader import torch.nn.functional as Fclass LiverDataset(Dataset): definit(self, npz_paths, patch_size(128, 128, 48)): self.paths npz_paths self.patch_size patch_sizedef __len__(self): return len(self.paths) * 200 # 每个病例采样200次 def __getitem__(self, idx): data np.load(self.paths[idx % len(self.paths)]) img, lbl sample_patch(data[image], data[label], self.patch_size) return (torch.tensor(img).unsqueeze(0).float(), torch.tensor(lbl).unsqueeze(0).float())def dice_loss(pred, target, smooth1.0): pred torch.sigmoid(pred) inter (pred * target).sum() return 1.0 - (2.0 * inter smooth) / (pred.sum() target.sum() smooth)def train_step(G, D, opt_G, opt_D, img, label, lam0.05): real_pair torch.cat([img, label], dim1) pred_logit G(img) pred_prob torch.sigmoid(pred_logit) fake_pair torch.cat([img, pred_prob.detach()], dim1)# 1. 更新判别器 opt_D.zero_grad() real_score D(real_pair) fake_score D(fake_pair) # LSGAN 标签平滑真图目标0.9假图目标0.1 loss_D (F.mse_loss(real_score, torch.ones_like(real_score) * 0.9) F.mse_loss(fake_score, torch.zeros_like(fake_score) * 0.1)) * 0.5 loss_D.backward() opt_D.step() # 2. 更新生成器 opt_G.zero_grad() fake_pair torch.cat([img, pred_prob], dim1) # 重新拼接保留梯度 adv_loss F.mse_loss(D(fake_pair), torch.ones_like(fake_pair[:, :1])) dsc_loss dice_loss(pred_logit, label) loss_G dsc_loss lam * adv_loss loss_G.backward() opt_G.step() return loss_D.item(), loss_G.item(), dsc_loss.item(), adv_loss.item()几个实现细节容易踩坑。第一个是fake_pair在更新生成器时必须重新用带梯度的pred_prob拼接前面传给判别器的pred_prob已经detach过第二个是adv_loss用的是骗过判别器的目标让D认为假图是真的所以损失里对fake的score向1逼近第三个是两个optimizer必须各自step前单独zero_grad不能图省事一起清。记录loss时把dice和adv分开记如果dice一直掉但adv在涨就该调λ或降判别器学习率。4.3 推理与后处理滑动窗口、阈值与最大连通域推理阶段直接整图切块做滑动窗口预测窗口重叠一半、概率图取平均能明显减少patch边缘的接缝伪影。后处理固定三板斧sigmoid得到概率阈值0.5转二值再用scipy.ndimage.label提取连通域并保留最大连通域。肝脏在腹部是最大的单一器官这个先验在大多数病例上都成立。def predict_volume(model, volume, patch_size(128, 128, 48), stride(64, 64, 24)): model.eval() d, h, w patch_size out np.zeros_like(volume, dtypenp.float32) count np.zeros_like(volume, dtypenp.float32)# 滑动窗口位置末尾补一刀避免漏掉最后几个切片 z_pos list(range(0, volume.shape[0] - d 1, stride[0])) if z_pos[-1] d volume.shape[0]: z_pos.append(volume.shape[0] - d) y_pos list(range(0, volume.shape[1] - h 1, stride[1])) if y_pos[-1] h volume.shape[1]: y_pos.append(volume.shape[1] - h) x_pos list(range(0, volume.shape[2] - w 1, stride[2])) if x_pos[-1] w volume.shape[2]: x_pos.append(volume.shape[2] - w) with torch.no_grad(): for z in z_pos: for y in y_pos: for x in x_pos: patch volume[z:zd, y:yh, x:xw] inp torch.tensor(patch).unsqueeze(0).unsqueeze(0).float().cuda() prob torch.sigmoid(model(inp)).cpu().numpy()[0, 0] out[z:zd, y:yh, x:xw] prob count[z:zd, y:yh, x:xw] 1 prob_map out / np.maximum(count, 1) binary (prob_map 0.5).astype(np.uint8) labeled, num ndimage.label(binary) if num 0: largest np.argmax(np.bincount(labeled.ravel())[1:]) 1 binary (labeled largest).astype(np.uint8) return prob_map, binary滑动窗口要注意末尾切片覆盖问题range生成的位置不一定能到最后一个切片要手动补一刀。一个256×256×120的病例stride取patch一半大概跑几十个patchJupyter里几分钟内完成。阈值0.5不是铁律发现预测边界外扩可以试0.55发现小结构被吞掉试0.45。另一个重要步骤是推理结果要变换回原始坐标系。前面预处理时保存了原始spacing、方向矩阵和ROI裁剪的切片信息推理后用SimpleITK把二值图重采样回原始分辨率再填回ROI区域。这个变换很容易出错最常见的问题是忘记处理方向矩阵导致预测结果在冠状位上左右颠倒。5. GAN肝脏分割常见问题排查五个我踩过的坑5.1 判别器过早碾压生成器损失震荡不收敛现象训练前几十轮判别器损失一路降到0.1以下生成器的Dice损失在0.5附近来回震荡验证集Dice不升反降。这是GAN类分割项目最经典的翻车现场本质上不是代码bug而是博弈失衡。原因判别器任务比生成器简单得多。生成器要在128×128×48的patch里把肝脏边界画出来判别器只需要分辨像不像标签而生成器早期输出一堆噪声图判别器一学就会。判别器太强之后传给生成器的梯度基本在零附近生成器得不到有效信号。解决先把判别器学习率降到原来的一半甚至四分之一确认标签平滑已加上0.9/0.1对抗损失确认用的是LSGAN而不是BCE如果还不行把训练顺序反过来生成器每轮更新两次、判别器更新一次。GAN训练中判别器和生成器的平衡多少带点玄学不要指望一组参数通吃所有数据集先记录loss曲线再动手调参。5.2 GPU显存溢出patch size调小后Dice反而掉现象报CUDA out of memory于是把patch从128×128×48缩到64×64×48训练跑起来了但验证Dice从0.92掉到0.78边缘明显粗糙。原因patch的x/y从128缩到64时感受野跟着缩了一半肝脏边界周围上下文看不全生成器只能看到局部纹理分割结果像是猜边界。显存和效果出现矛盾时先别急着缩patch。解决保住x/y方向的128优先缩z轴到32再不行开AMP混合精度训练显存直接省一半还不行用PyTorch的gradient checkpointing用计算换显存。最后一招是退化成2D slice训练加3D后处理Dice可能低一到两个点但能在老显卡上跑完。实际项目里先看训练时单batch的显存占用峰值确认瓶颈在激活值还是优化器状态再决定用哪种方案不要盲目缩patch。5.3 小肝脏和小病灶漏检损失被大肝脏主导现象验证集里体积小的肝脏、肝脏边缘的肿瘤被整块吞掉模型输出比标签小一圈小病例Dice只有0.7左右。原因Dice损失对大目标和小目标的贡献按体素数量加权一个大肝脏占几万个体素小病灶只有几百个大目标的梯度把小目标的信号淹没。加上补丁采样时前景位置偏向大肝脏重心小肝脏很少被采到。解决采样阶段按肝脏体积反比加权体积越小的病例采样概率越高损失换成TopK Dice只对预测最不确定的K个体素算损失K取前景体素总数的1到2倍把模型能力集中在难样本上。还有一个立竿见影的做法把patch采样时从前景区取中心点改成从肝脏边缘取中心点让边界区域被更充分覆盖。小病灶漏检问题往往不是模型能力不够而是训练时根本没看过足够多的难样本。5.4 验证集Dice高但实际切片边缘差指标盲区现象验证集平均Dice 0.92整体满意但把预测和标签叠在切片上逐层看发现边界整体外扩1到2个像素左叶边缘尤其明显。Dice 0.92掩盖了这个系统性偏移。原因Dice是重叠率指标整体外扩造成的重叠损失很小看不出系统性偏置。这类问题需要表面距离指标才能量化边界是不是恰好落在组织边界上。解决验证集评估加上HD95和平均表面距离这两个指标用SimpleITK的HausdorffDistanceImageFilter直接算。经验上肝脏分割HD95在5mm以内才算临床可用。如果确认是整体外扩把推理阈值从0.5调到0.6再测一遍二值化阈值本身就是一个可调的后悔药。如果阈值调到0.65还压不住外扩就要考虑模型输出的边界置信度整体偏低这时候该检查判别器是否把边界结构教歪了。5.5 Jupyter里路径和重启问题默认保存路径与断点续训现象下载的notebook在本地打开后报FileNotFoundError仔细检查发现相对路径写的是data/xxx.nii但kernel的工作目录根本不在notebook所在目录。另一个现象是训练到第80轮时Jupyter内核崩了重启后变量全丢得从第0轮重新跑。原因Jupyter Notebook默认保存路径决定的是文件存放位置不等于kernel启动时的工作目录notebook代码把整个会话状态放在内存里内核一重启模型、优化器、epoch全部归零。解决在notebook第一个代码单元格里用BASE_DIR os.path.dirname(os.path.abspath(file))拼接所有路径或者直接用os.chdir切到目标目录。训练部分加断点续训每个epoch结束保存checkpoint包含模型权重、优化器状态、当前epochnotebook重新执行时先扫描weights目录有没有checkpoint有就从最新断点恢复。import torchckpt torch.load(os.path.join(BASE_DIR, weights, last.pt)) G.load_state_dict(ckpt[model]) D.load_state_dict(ckpt[disc]) opt_G.load_state_dict(ckpt[optG]) opt_D.load_state_dict(ckpt[optD]) start_epoch ckpt[epoch]这个习惯能避免重跑几十个epoch。尤其是第80轮崩掉的时候你会感激当时多写的那三行恢复代码。断点续训不只是一个工程细节对GAN这种训练波动大的任务来说它还意味着你随时能回到损失还没崩坏的版本重新调参。6. 分割效果验证技巧切片可视化与HD95指标一起看6.1 切片级逐层检查一眼看穿问题所在用一个单独的可视化notebook把验证集每个病例的横断面按z轴每隔20层抽一张图三列并排原CT、标签轮廓、预测轮廓用contour把轮廓叠在CT上。这个检查最大的价值是能直观定位问题类别边缘整体外扩是阈值问题边缘毛刺是后处理缺形态学操作小结构丢失是采样或损失问题边界在血管附近断裂是感受野不够。这类问题光看Dice曲线几乎看不出来。6.2 HD95与体积误差给临床同事看得懂的指标最终评估表格里列四个指标Dice、HD95、平均表面距离、体积误差预测体积除以标签体积减1。在内部验证集上Dice达到0.90以上、HD95在5mm以内是肝脏分割项目的可接受基线。体积误差是临床最敏感的指标直接影响术前规划和术后评估的实际使用。把这些指标按病例列成表比只给一个平均Dice有说服力得多。指标临床可用基线说明Dice 0.90整体重叠率HD95 5mm边界最大偏差的95分位平均表面距离 2mm边界的平均偏差体积误差 5%直接影响临床决策我最早跑GAN分割时只盯Dice直到一次给临床同事演示对方问边缘看起来有点往外扩你们的表面距离是多少我才意识到黑匣子式的评估在医学场景里根本交不了差。后来每个项目迭代必看切片可视化加HD95这个习惯帮我避免了好几次假性达标。希望这次整理的模型结构、训练代码和排查记录能帮你在Jupyter里少踩几个坑把GAN在3D肝脏分割上的效果真正验证出来。本文还有配套的精品资源点击获取
返回列表