资讯动态

基于U-Net的肝脏MRI分割实战:从预处理到体积计算全流程

发布时间:2026/9/15 22:05:47 来源:尧图企业网站定制
经常有同学拿着MRI片子来找我“帮我看一下这批肝脏图像把轮廓自动勾出来科室手动勾了三天了。” 我第一次接触肝脏MR分割时也觉得自己稳了CT肝脏分割在LiTS上都做到0.96 Dice了MRI不就是换个序列嘛。结果第一次验证集Dice只有0.72肝门附近边界漏得一塌糊涂胆管和血管全被判成背景。后来重新梳理数据预处理、损失函数和后处理才把Dice稳定在0.90以上体积误差控制在可接受范围。这篇文章就把这套基于U-Net的医学图像处理流程完整拆开从MRI的肝脏分割难点、数据预处理、网络结构原理到PyTorch代码复现、训练策略、后处理和体积计算一条线讲清楚。内容上默认你已经有基础的Python和深度学习经验但不要求你熟悉医学图像格式。想做其他腹部器官分割、甚至CT上分割的也可以直接照搬这套流程。我会把我踩过的坑、试错的调整过程都写出来尽量让你少走弯路。1. 为什么肝脏MRI分割是“一看就会一训就崩”的任务1.1 肝脏分割与自然图像分割的本质区别自然图像分割比如人像抠图、街景物体分割主要靠颜色、纹理、边缘这些线索模型可以很自然地学到“有没有明显的物体边界”。但医学图像里的肝脏不是这么回事肝脏与周围组织脾脏、胃壁、肾脏、肌肉在MRI上的灰度范围高度重叠不能简单靠像素值区分。肝脏边界在很多slice上并不清晰尤其是在肝门区域血管、胆管、淋巴结和周围组织混在一起别说模型医生勾画也需要结合上下层判断。肝脏形态因人而异右叶大左叶小左叶还可能卷曲不同人之间差异很大。同一个人的不同slice形状也是突变的。肝内肿瘤、囊肿、脂肪浸润等会改变局部灰度模型容易把这些区域误判为背景或者把病变边缘当成肝脏边界。所以肝脏分割本质上是一个“强先验”任务模型必须记住肝脏的解剖位置、形状、相对大小关系不能只靠局部特征识别。这也是为什么单靠传统图像处理方法阈值、区域生长、活动轮廓在MRI上非常不稳定而U-Net这类深度学习模型能有明显优势。1.2 MRI相比CT难度又上了一个台阶很多公开肝脏分割研究都基于CT因为CT的亨氏单位HU本身有物理意义空气、脂肪、软组织、骨骼的灰度分布是明确的归一化简单直接。而MRI存在几个很麻烦的特性导致模型更容易“翻车”灰度没有绝对物理意义。同一个人、同一台设备、不同序列T1、T2、Dixon出来的肝脏灰度完全不同甚至同一个序列在不同扫描参数下灰度分布也会漂移。偏场效应bias field。MRI图像中同一组织在图像不同位置的灰度会平滑变化。如果没有做偏场校正或对应的数据增强模型很容易学到“图像左上角就是背景”这种伪规律。呼吸运动伪影。腹部MRI扫描时间长肝脏受呼吸影响会发生位移导致slice之间肝脏位置跳变边界重影。模型在训练时看到这种模糊边界很容易输出不确定区域。层厚与层间距大。很多腹部MRI的层厚是5-8mm层内分辨率只有1mm左右各向异性明显。如果你把3D体数据直接按2D切片丢进U-Net相邻层之间的形态变化很剧烈边缘容易出现断裂。我最初犯的错就是把CT分割的那套管线直接搬到MRI上对CT做窗宽窗位、归一化到0-1然后就用U-Net训练。结果在验证集上Dice还行但预测出来的mask在血管附近有明显“锯齿”医生一看就说不能用。这说明MRI场景必须单独处理不能拿CT经验硬套。2. 数据与预处理决定分割上限的那80%深度学习的经验是“模型决定下限数据和预处理决定上限”。在肝脏MRI分割这个任务里预处理尤其重要因为MRI没有统一的灰度标准同一个肝脏在不同序列里的表现差异很大。下面的流程是我在多个MRI腹部数据集上验证过的通用方案。如果你用的数据集格式不同按这个思路调整即可。2.1 数据来源与标注格式可用的公开MRI肝脏数据不算多常见的是CHAOSCombined Healthy Abdominal Organ Segmentation包含T1和T2序列的肝脏、肾脏、脾脏标注另外一些论文会使用院内自采数据标注格式通常是NIfTI.nii.gz或NRRD。很多CT上的公开肝脏数据集比如LiTS不能直接拿来做MRI训练因为分布差异太大迁移效果很差。数据读取我推荐用Two常用的库选一个就行NIfTI格式用nibabel读取拿到shape(H, W, D)的体数据。NRRD格式用pynrrd读取和NIfTI类似。读入后第一件事不是训练而是确认数据方向一致。不同设备和标注软件导出的图像原点、轴向可能不同。我建议统一转成RASRight-Anterior-Superior方向否则你训练时看着是“左边”实际可能是“右边”后处理阶段做连通域、体积计算时也会出错。nibabel里可以通过affine判断方向如果发现方向不一致用nibabel.as_closest_canonical统一。2.2 重采样、裁剪与归一化的具体操作MRI图像不是均匀体素。层内分辨率可能是0.9mm×0.9mm层厚5mm直接训练会出现一个问题模型在同一张2D图里看的是平面细节但相邻slice之间的解剖连续性很差。常见做法有两种2D U-Net路线保留原始层厚把每一层切片当成独立样本训练。操作简单速度快但需要后处理时关注层间连续性。3D U-Net路线将体数据重采样到各向同性比如1mm×1mm×1mm然后输入3D卷积网络。效果通常更好但显存占用大训练时间长。本文以2D U-Net为主线。预处理时我会做以下几步裁剪非零区域。MRI图像四周通常有黑色背景如果直接送入网络模型需要浪费大量参数在“区分背景和人体”上。我会先计算整个volume的非零掩膜取所有非零坐标的包围盒裁剪到包围盒范围同时记录裁剪偏移量。这样输入从512×512变成256×256左右训练速度快很多。重采样到统一平面分辨率。如果不同病例的平面分辨率差异过大我会用scipy.ndimage.zoom统一重采样到1mm×1mm仅平面层厚不变。这是基于常见实践的补充原始需求没有规定统一分辨率但统一分辨率能显著提升跨病例泛化性。强度归一化。MRI没有CT的HU标准我用z-score归一化先计算整个volume的均值和标准差只计算裁剪后的非零区域避免背景把均值拉低然后做(x - mean) / std。有的同学喜欢对每张slice分别归一化实测下来不稳定因为肝门附近某些slice的整体灰度偏差会被放大。下面是一个可运行的预处理示例import nibabel as nib import numpy as np from scipy.ndimage import zoom def load_volume(path): img nib.load(path) img nib.as_closest_canonical(img) data img.get_fdata() spacing img.header.get_zooms()[:3] # (x, y, z) spacing return data, spacing def preprocess_volume(data, target_plane_spacing1.0): # data shape: (H, W, D) d, h, w data.shape # 沿Z轴扫描找非零区域包围盒 nonzero data 0 coords np.argwhere(nonzero) z_min, z_max coords[:, 0].min(), coords[:, 0].max() y_min, y_max coords[:, 1].min(), coords[:, 1].max() x_min, x_max coords[:, 2].min(), coords[:, 2].max() data_crop data[z_min:z_max1, y_min:y_max1, x_min:x_max1] # 平面重采样到target spacing # 假设原始平面spacing是raw_spacing_xy重采样缩放因子 raw / target # 这里简化为固定shape实际使用中需要拿到原始spacing再算 # 比如目标平面尺寸为256x256 if data_crop.shape[1] ! 256 or data_crop.shape[2] ! 256: z, y, x data_crop.shape zoom_factor (1, 256/y, 256/x) data_crop zoom(data_crop, zoom_factor, order3) # z-score归一化基于非零区域 mask data_crop 0 mean data_crop[mask].mean() std data_crop[mask].std() data_norm (data_crop - mean) / (std 1e-8) data_norm[~mask] 0 return data_norm.astype(np.float32)2.3 数据增强必须针对MRI设计自然图像里的随机裁剪、颜色抖动在MRI上要谨慎。因为肝脏的位置和解剖形状有强先验过度的几何变形会让模型学到“肝脏可以被拉成长条”这种错误认知。我常用的增强组合随机旋转±15度概率0.5水平/垂直翻转概率0.5随机缩放0.9到1.1概率0.3弹性形变使用albumentations的ElasticTransformsigma取8概率0.3用来模拟呼吸运动造成的轻微形变强度扰动对图像乘以一个0.9到1.1之间的随机系数再加一个0.05以内的随机偏移。这比自然图像的颜色抖动柔和但能模拟不同扫描参数带来的灰度波动。这里有一条非常关键的经验数据增强一定要对image和mask同步做尤其弹性形变不能只增强图像不增强标签。用albumentations的Compose(transforms, additional_targets{mask: mask})可以直接做到。import albumentations as A train_transform A.Compose([ A.Rotate(limit15, p0.5), A.Flip(p0.5), A.RandomScale(scale_limit0.1, p0.3), A.ElasticTransform(alpha1, sigma8, p0.3), A.RandomBrightnessContrast(brightness_limit0.1, contrast_limit0.1, p0.3), ], additional_targets{mask: mask})2.4 数据划分按患者划分不是按切片划分这是最容易踩的坑。如果直接把所有患者的所有slice打乱然后随机划分训练集和验证集同一个患者的相邻slice会同时出现在两边验证集Dice会虚高到0.95以上实际临床应用一换新患者直接掉到0.7。正确的做法是按患者维度划分训练集患者和验证集患者完全分开。一般来说训练集占70%-80%验证集占10%-15%测试集留10%-15%。如果数据量很少用K折交叉验证更稳。我自己的习惯是先把患者列表按stratified方式切好再在训练时用Dataset按slice索引加载。测试的时候只评估完整volume不评估单个slice因为临床使用一定是看整个肝脏体积和形状。3. U-Net核心结构拆解编码器、解码器与跳跃连接的真实分工3.1 为什么是编码-解码结构图像分割可以理解为“给每个像素分类”。一个直接的思路是用卷积网络逐像素分类但问题在于想要判断“这个像素是不是肝脏”需要它周围足够大的上下文信息而卷积层堆叠越深特征图分辨率越低边缘位置信息丢失越严重。U-Net用两条路径解决这个矛盾编码器下采样路径通过卷积和池化/步长卷积不断缩小特征图尺寸、增加通道数让网络看到越来越大的感受野。底层特征图虽然分辨率低但能编码“这团组织大概是肝脏”的全局语义。解码器上采样路径把低分辨率特征逐步恢复回原图尺寸同时通过跳跃连接补充高分辨率细节让模型既能理解全局结构又能保留精细边界。没有解码器的结构比如纯分类的骨干网络直接接全连接输出是“图里有没有肝脏”而分割要求输出是“每个像素是不是肝脏”这个逐像素的预测必须由解码器完成。3.2 跳跃连接是U-Net真正的“灵魂”很多刚入门的朋友以为U-Net的关键是“U形对称结构”其实真正的核心是跳跃连接skip connection。如果不加跳跃连接同样深度的编码-解码网络会出现一个问题解码器在恢复分辨率时丢失了底层的高频细节边缘、纹理只能依赖深度特征“脑补”导致预测mask边缘特别糊。跳跃连接把编码器每一层的特征直接拼到解码器对应层等于给解码器递了一份“高分辨率地图”让它知道精确的边界在哪里。以肝脏分割为例肝门附近胆管和血管的细长结构在深度特征图里早就被池化抹掉了但跳跃连接保留下来的高分辨率特征能提供这些细节模型才能输出完整的肝脏轮廓。3.3 如果只改一个地方优先加深还是加宽U-Net的参数量已经不少原始结构的通道数64-128-256-512-1024对256×256输入非常合适。实际复现时我的建议是不要一上来就加Deep Supervision多层深度监督先把基础U-Net调到稳定。如果显存紧张把第一层通道数降到32或48效果通常只掉一点。如果分割结果边缘肉眼看还是粗糙优先考虑在解码器最后一层之前加一个3x3卷积或ASPP模块而不是无脑把网络加深。如果训练集足够大把普通卷积替换为ResNet风格的残差块稳定性会有提升但训练时间会变长。下面是我常用的PyTorch实现结构清楚可以直接跑。import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, in_channels1, out_channels1, features(64, 128, 256, 512, 1024)): super().__init__() self.encoder nn.ModuleList() self.pool nn.MaxPool2d(2) self.decoder nn.ModuleList() self.up nn.ModuleList() for f in features: self.encoder.append(DoubleConv(in_channels, f)) in_channels f self.bottleneck DoubleConv(features[-1], features[-1] * 2) for idx in range(len(features) - 2, -1, -1): self.up.append(nn.ConvTranspose2d(features[idx1], features[idx], kernel_size2, stride2)) self.decoder.append(DoubleConv(features[idx1], features[idx])) self.final_conv nn.Conv2d(features[0], out_channels, kernel_size1) def forward(self, x): skip_connections [] for enc in self.encoder: x enc(x) skip_connections.append(x) x self.pool(x) x self.bottleneck(x) skip_connections skip_connections[::-1] for idx in range(len(self.up)): x self.up[idx](x) skip skip_connections[idx 1] # 第一层skip对应features[0] # 如果尺寸对不齐做一次中心裁剪或插值 if x.shape ! skip.shape: x nn.functional.interpolate(x, sizeskip.shape[2:], modebilinear, align_cornersFalse) x torch.cat([skip, x], dim1) x self.decoder[idx](x) return self.final_conv(x)这个实现里我用的是features(64,128,256,512,1024)输入输出尺寸一致不需要像原版那样裁剪。注意skip_connections[::-1]之后索引0对应的是编码器最后一层1024通道但up[0]的输入是bottleneck输出2048通道输出是1024通道因此拼接时用的skip应该是索引1512通道而不是索引0。代码里写的是skip_connections[idx 1]这样索引0时拿到的就是原先第4层512通道的特征逻辑正确。3.4 2D还是3D怎么选如果只看单张slice做2D分割模型很难利用上下层的解剖连续性在边界模糊区域容易出现“这层看不出来肝脏到哪了”的情况。改进方案是用多个相邻slice作为输入通道比如取当前slice以及上下各2层的slice组成一个5通道输入相当于给了网络一点3D上下文。这个技巧在2D U-Net框架下非常有效显存代价也很小。如果项目目标是直接计算肝脏体积且训练数据有完整3D标注3D U-Net会更好但3D网络在显存和训练时间上的开销大约是2D的4-8倍。我的建议先跑通2D基线再考虑要不要上3D不要一上来就追求高配结构。4. 损失函数、训练超参和评估Dice不是万能的4.1 为什么不用纯交叉熵医学图像分割最大的问题就是前景背景极度不平衡。肝脏在一张256×256的slice里占比通常在5%-20%如果背景占80%以上直接用小批量的交叉熵损失模型只要把整张图都预测为背景损失也不会很大。解决办法不是让模型关注单个像素的损失而是让模型关注“预测区域和真实区域的重叠度”。Dice Loss就是这么来的。给定预测经过sigmoid后的概率写为p和标签0或1写为ySoft Dice定义是Dice (2 * Σ(p_i * y_i) smooth) / (Σ(p_i^2) Σ(y_i^2) smooth)Loss 1 - Dice这里p_i和y_i是逐像素值smooth取1e-5防止除零。这套定义可以直接对预测概率求导所以它不是一个只看分割结果的指标而是实实在在的损失函数。我在实际训练中用的更多是BCE Dice组合损失bce nn.BCEWithLogitsLoss()(logits, target) dice dice_loss(torch.sigmoid(logits), target) loss bce diceBCE负责给每个像素一个平滑的梯度信号Dice负责拉高前景背景重叠度。二者组合通常比单独用Dice收敛更稳。4.2 训练超参数组合参考下面的超参组合是我在torch环境下跑MRI肝脏分割的常见稳定配置基于常见的Adam优化器实践超参数推荐值说明输入尺寸256×256平衡精度和显存Batch Size1611GB显存可跑优化器Adamlr1e-4weight_decay1e-5学习率调度ReduceLROnPlateaupatience5factor0.5最大epochs100实际通常30-50收敛Early Stoppingpatience15监控验证集Dice混合精度torch.cuda.amp训练速度提升30%-50%如果训练过程中发现验证集Dice振荡明显先降学习率不要急着改网络结构。我把学习率调大比如1e-3时遇到过验证Dice在0.8附近剧烈抖动降到1e-4后立刻稳定下来。4.3 评估指标不能只看DiceDice是重叠率指标但它对边界偏移不敏感。一个在肝脏外围整体膨胀一圈的maskDice可能还是0.93但医生一眼就能看出来边界不对。所以我推荐同时计算Dice系数主要看区域重叠度。IoUJaccard另一个重叠率指标但对小目标更敏感。平均表面距离ASD计算预测表面到真实表面的平均距离单位mm。这个指标直接反映边界贴合程度。95%豪斯多夫距离HD95反映最大的边界偏差单位mm。ASD很小但HD95很大说明局部有严重偏差。用SimpleITK可以方便地计算import SimpleITK as sitk def dice_score(pred_mask, true_mask): intersection (pred_mask * true_mask).sum() return (2.0 * intersection) / (pred_mask.sum() true_mask.sum() 1e-6) def surface_distance_metrics(pred_sitk, true_sitk): # pred_sitk / true_sitk 是sitk.Image像素类型必须为uint8 signed sitk.SignedMaurerDistanceMap(true_sitk, useImageSpacingTrue, insideIsPositiveFalse) pred_surface sitk.LabelContour(pred_sitk) dist signed * sitk.Cast(pred_surface, sitk.sitkFloat32) dist_mm [d for d in sitk.GetArrayFromImage(dist).flatten() if d ! 0] asd float(np.mean(np.abs(dist_mm))) if dist_mm else 0.0 hd95 float(np.percentile(np.abs(dist_mm), 95)) if dist_mm else 0.0 return asd, hd95如果验证集上的ASD大于2mm说明边界还不够好需要检查后处理而不是继续刷Dice。5. PyTorch复现从nii.gz数据到肝脏预测Mask的最短路径5.1 Dataset与DataLoader实现假设数据目录结构是data/ patient01/ img.nii.gz mask.nii.gz patient02/ img.nii.gz mask.nii.gz这里我按slice读取因为2D U-Net训练时按slice输入。为了提高效率可以在__init__阶段把所有患者的slice索引提前展开。下面的代码是完整可跑的import os import numpy as np import torch from torch.utils.data import Dataset, DataLoader class LiverMRIDataset(Dataset): def __init__(self, patient_dirs, transformNone, use_adjacent_slices0): self.slices [] self.transform transform self.use_adjacent_slices use_adjacent_slices for pdir in patient_dirs: img_path os.path.join(pdir, img.nii.gz) mask_path os.path.join(pdir, mask.nii.gz) img self._load_nifti(img_path) mask self._load_nifti(mask_path) # 预处理裁剪/重采样/归一化这里省略具体函数见第2节 img, mask preprocess_pair(img, mask) # 只保留有肝脏标注的slice否则全是背景会让训练低效 z_idx np.where(mask.any(axis(1, 2)))[0] for z in z_idx: self.slices.append((img, mask, z)) def _load_nifti(self, path): import nibabel as nib img nib.load(path) img nib.as_closest_canonical(img) return img.get_fdata().astype(np.float32) def __len__(self): return len(self.slices) def __getitem__(self, idx): img, mask, z self.slices[idx] # 输入单个sliceshape: (H, W) x img[z] # (H, W) y mask[z] # (H, W) if self.transform is not None: augmented self.transform(imagex, masky) x augmented[image] y augmented[mask] # 转成 (1, H, W) 和 (1, H, W) x torch.from_numpy(x).unsqueeze(0) y torch.from_numpy(y).unsqueeze(0) return x, y实际使用中不要每次都重新读NIfTI文件太慢了。更好的做法是第一次加载后存成.npy或者把所有切片放到内存里。如果数据量大再考虑用lmdb或h5py缓存。5.2 训练主循环训练循环不复杂但有几个细节会影响最终效果。下面是我的标准训练代码框架import torch.optim as optim from torch.cuda.amp import GradScaler, autocast def train_one_epoch(model, loader, optimizer, criterion, scaler, device): model.train() total_loss 0 for x, y in loader: x, y x.to(device), y.to(device) optimizer.zero_grad() with autocast(): logits model(x) loss criterion(logits, y) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() total_loss loss.item() return total_loss / len(loader) def validate(model, loader, device): model.eval() total_dice 0 with torch.no_grad(): for x, y in loader: x, y x.to(device), y.to(device) logits model(x) prob torch.sigmoid(logits) pred (prob 0.5).float() dice dice_score(pred, y) total_dice dice.item() return total_dice / len(loader) def dice_score(pred, true): smooth 1e-6 intersection (pred * true).sum() return (2.0 * intersection smooth) / (pred.sum() true.sum() smooth) # 示例criterion def combined_loss(logits, target): bce nn.functional.binary_cross_entropy_with_logits(logits, target) prob torch.sigmoid(logits) dice 1.0 - dice_score(prob, target) return bce dice这里要注意amp.autocast下计算Dice损失时dice_score中的prob是float32问题不大。但如果用了half精度sum可能会溢出所以scaler和autocast都要配对使用。验证阶段不需要amp正常float32即可避免精度问题影响指标判断。验证时要关闭梯度否则如果模型里有dropout或BN评估结果会不稳定。5.3 预测阶段逐slice预测再合成volume训练完成后预测阶段和训练阶段有一个关键差异不能用单张slice独立预测就完事。我推荐用滑窗方式同时考虑上下文。最简单的做法是对每个z轴位置取上下各2个slice拼接成多通道输入如果你的模型是按多通道训练的或者直接把连续5张slice都预测一遍再对z轴做中值投票。如果没有多通道模型可以用以下后处理技巧先逐slice预测得到概率图volume再在z轴方向做一次1D高斯平滑或者用3D连通域处理去掉孤立噪声。这样能明显减少单层预测造成的“闪变”现象。6. 从Dice分数到真实应用后处理与体积计算的几个关键动作6.1 最大连通域与孔洞填充U-Net输出的mask通常是二值图但会有一些散落的误检区域比如把一部分胃壁或血管预测成了肝脏。如果直接拿去做体积计算这些噪声会造成明显误差。我的标准后处理流程是去掉小连通域用scipy.ndimage.label标记连通域只保留最大的连通域。如果肝脏左叶和右叶在某个slice上完全断开这一步要小心不能盲目只保留最大的否则可能丢掉左叶。孔洞填充肝脏内部有血管、胆管预测mask里容易出现“洞”。如果目标是算肝脏体积这些孔洞通常应该被填充但如果目标是看血管侵犯就不能填。所以要根据下游任务决定。中值滤波在预测概率图上做scipy.ndimage.median_filter窗口3×3能消除孤立噪点同时保留边界。import numpy as np from scipy import ndimage def postprocess_mask(pred_prob, threshold0.5, min_volume_mm3100): mask (pred_prob threshold).astype(np.uint8) # 3D连通域只保留最大的 labeled, num_features ndimage.label(mask) if num_features 0: sizes ndimage.sum(mask, labeled, range(num_features 1)) max_label np.argmax(sizes) mask (labeled max_label).astype(np.uint8) # 孔洞填充按3D填充 mask ndimage.binary_fill_holes(mask).astype(np.uint8) # 按体积过滤如果体素体积已知可移除过小区域 return mask6.2 体积计算从像素到毫升肝脏体积在临床上有实际意义比如肝移植体积评估。计算体积不能直接数像素而是要结合体素的实际物理尺寸。假设体素间距是spacing_x, spacing_y, spacing_z单位mm那么每个体素的体积是voxel_volume_mm3 spacing_x * spacing_y * spacing_z liver_volume_ml mask.sum() * voxel_volume_mm3 / 1000.0 # 1ml1000mm3关于MRI的层厚问题如果原始数据层厚5mm训练时预处理没有改变z轴spacing那这个体积计算是准确的。但如果在预处理阶段做过重采样就一定要用重采样后的spacing不能沿用原始header里的数值。这个细节我踩过坑直接把原始spacing套到重采样后的mask上体积算出来明显偏小。6.3 边界质量的临床审查点不要只看Dice和体积。临床上更容易关注的点是肝门区是否被错误分割这是最容易出错的位置肝静脉、门静脉、胆管和肝组织边界模糊预测mask经常把这些结构漏掉或额外圈进来。最好单独做一个“肝门区域”的评估子集观察模型在这个区域的表现。肝脏边缘是否有系统性外扩如果模型倾向于把肝脏周围一圈软组织也包含进来体积会普遍偏大。此时后处理阶段可以加一个腐蚀操作或者调整阈值从0.5调到0.6看哪个对验证集最有效。极端slice的鲁棒性肝脏最上方的slice和膈肌、肺底紧挨着最下方的slice和肠管、右肾紧挨着模型在这里通常更不稳定。如果医生是用来做术前规划这些边缘slice出错影响不大如果是做放疗勾画就必须逐层检查。我实际经历过的例子模型在整体Dice 0.91的情况下肝门区域Dice只有0.72。后来在损失函数里对肝门区域加了更大的权重才把这个数字拉到0.82。所以单一全局指标会掩盖局部问题建议在评估脚本里把肝脏中心区域和边缘区域分开统计。7. 踩坑实录让模型“看起来正常用起来翻车”的四个细节7.1 按患者划分的验证集Dice虚高这个问题我在第2.4节提到过但还是要单独拎出来讲因为实在太容易犯。有一次我拿到一个50例患者的数据集图方便把所有slice混在一起随机切分验证集Dice飙到0.96我差点以为模型已经可以上线了。后来改成按患者划分同一模型直接掉到0.85。原因很简单同一个患者的相邻slice几乎一模一样模型“记住”了这个患者的解剖结构验证时当然表现好。医学图像的训练/验证分割必须确保任何患者的数据不能同时出现在训练集和验证集里。7.2 损失函数里没有处理“空slice”问题训练时如果把不含肝脏的slice也纳入训练模型需要学习的正样本很少Dice损失在空标签上会变成常数1导致梯度被空slice主导。解决方法是训练时只保留至少包含一小块肝脏的slice预测时则对所有slice都预测然后靠后处理过滤。但如果数据集里很多slice只有很小一块肝脏比如肝脏最顶部或底部完全过滤掉又会让模型看不到肝脏的“边界形态”。我的折中做法是保留肝脏面积占比在5%以上的slice小于5%的单独抽出来作为困难样本以较小概率混入训练集。7.3 后处理阶段手机误删左叶最大连通域保留在很多分割任务里是个“通用做法”但对肝脏分割要格外小心。有些病例的肝脏左叶和右叶在某个层面完全不连通如果只保留最大连通域左叶可能整块被删掉。我现在的处理方式是先按slice统计连通域数量如果整个3D体积里最大的连通域体积占全肝预测体积的90%以上才放心只保留最大连通域否则保留前两个或前三个连通域。简单说后处理规则必须根据预测结果的实际情况动态调整不能写死。7.4 归一化作用在整张图像上导致灰度对比被背景稀释MRI图像的背景区域是0如果直接对整个体数据做z-score而背景占据了大部分空间肝脏的灰度差异会被压缩得很小。正确的做法是先做非零区域裁剪再在裁剪后的体数据上计算均值和标准差。另一个容易被忽略的点是训练时的数据增强比如RandomBrightnessContrast会改变图像的灰度分布如果你在增强之后再归一化模型看到的输入分布会被打乱。我的做法是先归一化再做强度扰动这样扰动相当于在“标准分布”附近抖动而不是彻底改变分布。8. 从2D U-Net到3D模型与多序列融合的扩展思路如果已经跑通了上面的2D U-Net而Dice仍然卡在0.9以下大概率不是结构问题而是数据利用不充分。这时候可以考虑以下三个方向。8.1 使用多序列MRI信息腹部MRI通常有T1、T2、Dixon等序列不同序列对肝脏边界的显示各有优劣。比如T1对肝内血管显示比较清楚T2对病变更敏感。如果数据里有多个序列可以把它们作为模型的多个输入通道。对于2D U-Net输入通道从1变成3或4模型就能同时看不同对比度的图像。代价是模型参数量几乎不变训练时间增加很少但Dice通常有0.01-0.03的提升。需要注意的是多个序列的空间位置必须严格对齐。如果扫描时患者移动过不同序列的shape可能不一致需要先做配准否则直接把不同通道叠在一起模型会被“错位”特征带偏。8.2 把“相邻slice上下文”编码进2D模型另一种轻量级的上下文引入方式是把相邻的k个slice作为输入通道。比如当前slice是z输入是z-2、z-1、z、z1、z2五个slice标签仍然是z的mask。这样模型在单张slice的预测里能感知上下层结构边界连续性明显改善。我在实际实验里用过5通道输入ASD从1.8mm降到1.2mm左右。这个方案对显存友好训练速度和2D差不多强烈推荐作为白嫖上分手段。8.3 3D U-Net的引入门槛3D U-Net的真正价值在于它能直接建模体积的连贯性但它的代价是显存和训练时间。以输入patch 128×128×64为例第一层通道数1611GB显存勉强能跑但训练时间大约是2D的3-5倍。而且3D模型需要足够多的3D训练数据如果只有几十例患者很容易过拟合。我自己的建议是2D U-Net先作为baseline如果验证集ASD已经小于1.5mm临床基本能接受就不要再折腾3D了如果ASD一直降不下来再用3D模型。9. 实测下来最值得保留的几条经验给新手朋友一个可操作的“避坑顺序”先确认数据划分是不是按患者划分的。这一条错了后面所有指标都是自欺欺人。先做非零裁剪和z-score归一化再谈网络结构。同一批数据裁剪前Dice可能0.82裁剪后直接0.88。用BCEDice作为默认损失函数不要单独用BCE也不建议一开始就上Tversky、Focal这种复杂损失。训练时留一份“全volume”的验证集不要只看逐slice的Dice。逐slice Dice高不代表整个体积形状对。我会定期用当前模型对完整volume做预测然后做3D连通域检查和后处理用ASD和HD95评估边界质量这一步能提前发现很多指标上看不出来的问题。后处理是最后一根救命稻草。如果模型预测的mask边缘粗糙先别急着换架构调整预测阈值、做中值滤波、填孔、选最大连通域往往能把Dice提到0.01-0.02。我在多个项目里用过同一套U-Net基线最后排名靠前的不是网络改得最花哨的版本而是数据预处理和后处理做扎实的版本。医学图像处理有个特点噪声大、样本少、标注主观所以任何一步“省事”的操作最终都会在某个意想不到的地方还回来。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价