Unet+Resnet 实战:子宫颈细胞核分割的三大关键技巧
发布时间:2026/9/28 19:11:16来源:尧图网络
简介这份资源是面向深度学习图像分割初学者与实战开发者的完整项目包以Unet为主干网络、Resnet为backbone针对子宫颈细胞核分割这一二分类任务提供从数据到权重的一站式方案。压缩包共804个文件以jpg与png图像数据为主另含py训练与推理脚本、pth权重文件、txt日志及xml标注等整体约113MB目录结构清晰。项目仅训练50个epochs全局像素准确率即达0.89miou为0.82继续增大训练轮次性能仍有提升空间。代码支持多尺度训练自动将数据随机缩放至0.5至1.5倍并通过compute_gray函数读取mask灰度值、自动定义输出通道便于扩展多类别分割学习率采用cos衰减训练与测试的损失和iou曲线可在run文件中查看日志完整记录各类别iou、recall、precision及全局准确率。推理时只需将待测图像放入inference目录并运行predict脚本无需额外参数。目前已有228人学习适合希望快速上手医学细胞核分割、理解多尺度训练与多类别扩展的读者参考。1. 子宫颈细胞核分割UnetResnet 实战里最容易被低估的三件事子宫颈细胞核分割是医学图像分割里一个非常典型的二分类任务前景是细胞核背景是细胞质和间质。很多做深度学习毕设或者医学图像入门的朋友第一反应是直接拿一个 Unet 跑起来结果 Dice 卡在 0.7 上不去就开始怀疑人生。我一开始也这样后来发现真正拉开差距的不是网络有多深而是三件事编码器到底用不用 Resnet 预训练、多尺度训练怎么加、以及多类别分割的标签映射有没有做对。这个实战方向适合已经跑通过 Unet 基础代码、想把自己的分割精度从「能看」推到「能用」的人。下面我按自己踩过的顺序把 UnetResnet 在多类别分割和子宫颈细胞核数据上的落地路径拆开讲参数和坑都写清楚你照着改就能复现。2. 为什么 Unet 的编码器要换成 Resnet从梯度消失到预训练红利2.1 原生 Unet 编码器的两个硬伤原生 Unet 的编码器就是几个 3x3 卷积加最大池化堆出来的层数一深梯度回传就开始玄学。子宫颈细胞核分割的图像特点是细胞核密集、边界模糊、染色差异大浅层卷积提取的纹理特征根本不够用。更麻烦的是医学图像数据集通常只有几百张从零训练一个深层编码器过拟合几乎是必然的。我试过在 400 张涂片图像上从零训 Unet训练集 Dice 冲到 0.95验证集死活过不了 0.72这就是典型的编码器表达能力不足加上数据量不够。Resnet 的残差连接解决的是梯度问题而 ImageNet 预训练权重解决的是数据量问题。把 Unet 的编码器换成 Resnet34 或 Resnet50加载预训练权重相当于让网络先学会「什么是边缘、什么是纹理」再迁移到细胞核分割上。这一步的收益在子宫颈数据上非常明显我自己的对比是验证集 Dice 从 0.72 直接拉到 0.81训练轮数还少了三分之一。2.2 用 torchvision 替换编码器的具体写法下面这段代码是把 Unet 编码器换成 Resnet34 的最小改动版本核心思路是用 Resnet 的 layer0 到 layer4 替换原来的下采样路径解码器保持原样。import torch import torch.nn as nn import torchvision.models as models class ResNet34Unet(nn.Module): def __init__(self, num_classes2, pretrainedTrue): super().__init__() # 加载 Resnet34 主干用预训练权重 resnet models.resnet34(weightsIMAGENET1K_V1 if pretrained else None) # 编码器逐层拆出 Resnet 的 stage self.encoder0 nn.Sequential(resnet.conv1, resnet.bn1, resnet.relu) # 1/2 self.pool resnet.maxpool self.encoder1 resnet.layer1 # 1/4 self.encoder2 resnet.layer2 # 1/8 self.encoder3 resnet.layer3 # 1/16 self.encoder4 resnet.layer4 # 1/32 # 解码器每层上采样后与编码器特征拼接 self.up4 nn.ConvTranspose2d(512, 256, 2, stride2) self.dec4 self._block(512, 256) self.up3 nn.ConvTranspose2d(256, 128, 2, stride2) self.dec3 self._block(256, 128) self.up2 nn.ConvTranspose2d(128, 64, 2, stride2) self.dec2 self._block(128, 64) self.up1 nn.ConvTranspose2d(64, 64, 2, stride2) self.dec1 self._block(128, 64) self.final nn.Conv2d(64, num_classes, 1) def _block(self, in_ch, out_ch): return 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): # 编码路径 e0 self.encoder0(x) # 1/2, 64 e1 self.encoder1(self.pool(e0)) # 1/4, 64 e2 self.encoder2(e1) # 1/8, 128 e3 self.encoder3(e2) # 1/16, 256 e4 self.encoder4(e3) # 1/32, 512 # 解码路径逐层拼接 d4 self.dec4(torch.cat([self.up4(e4), e3], dim1)) d3 self.dec3(torch.cat([self.up3(d4), e2], dim1)) d2 self.dec2(torch.cat([self.up2(d3), e1], dim1)) d1 self.dec1(torch.cat([self.up1(d2), e0], dim1)) return self.final(d1)这段代码里最关键的是weightsIMAGENET1K_V1它决定了你是否吃到预训练红利。num_classes2对应二分类输出如果你做多类别分割这里改成类别数加背景。解码器的_block里用了两次卷积加 BN 加 ReLU这是为了在拼接后重新融合特征。注意torch.cat的维度是 1也就是通道维拼接前要确保上采样后的空间尺寸和编码器特征一致否则会报尺寸不匹配。我一般会在上采样后加一个F.interpolate兜底防止输入尺寸不是 32 的整数倍时翻车。2.3 预训练权重的加载策略与冻结技巧加载预训练权重后不要一上来就全网络微调。我的习惯是前 5 个 epoch 冻结编码器只训解码器让解码器先适应编码器输出的特征分布。5 个 epoch 后再解冻用较小的学习率比如 1e-4做全网络微调。这样做的好处是避免随机初始化的解码器在初期产生大梯度把预训练权重带偏。如果你数据量特别少比如只有 200 张可以一直冻结到 layer2只微调 layer3 和 layer4。3. 多尺度训练在细胞核分割里的正确打开方式3.1 多尺度训练不是简单 resize多尺度训练的核心目的是让模型对细胞核大小变化鲁棒。子宫颈涂片里不同视野下的细胞核像素面积可能差三到五倍如果只用一个固定尺寸训练模型很容易对某一类尺度过拟合。但多尺度训练不是把图片随便 resize 一下就完事那样会引入插值伪影尤其是细胞核边界本身就不清晰的时候。我试过直接对原图做 0.5 到 1.5 倍的随机缩放验证集 Dice 反而掉了 0.02后来发现是缩放后的标签也用了同样的插值导致边界像素被模糊了。正确的做法是对图像用双线性插值对标签用最近邻插值。这样标签的边界不会产生中间值保持二值或者多类别的离散性。另外缩放范围不要太大0.75 到 1.25 倍就够了再大就会让细胞核要么变成噪点要么糊成一团。3.2 多尺度训练的 Dataset 实现下面是一个带多尺度增强的 Dataset 写法核心是在__getitem__里随机选一个缩放比例然后分别处理图像和标签。import random import numpy as np import torch from torch.utils.data import Dataset import cv2 class CervixDataset(Dataset): def __init__(self, image_paths, mask_paths, base_size256, scale_range(0.75, 1.25)): self.image_paths image_paths self.mask_paths mask_paths self.base_size base_size self.scale_range scale_range def __len__(self): return len(self.image_paths) def __getitem__(self, idx): img cv2.imread(self.image_paths[idx]) # BGR img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) # 随机选缩放比例 scale random.uniform(*self.scale_range) new_h int(self.base_size * scale) new_w int(self.base_size * scale) # 图像用双线性标签用最近邻 img cv2.resize(img, (new_w, new_h), interpolationcv2.INTER_LINEAR) mask cv2.resize(mask, (new_w, new_h), interpolationcv2.INTER_NEAREST) # 随机裁剪回 base_size保证 batch 内尺寸一致 if new_h self.base_size and new_w self.base_size: top random.randint(0, new_h - self.base_size) left random.randint(0, new_w - self.base_size) img img[top:topself.base_size, left:leftself.base_size] mask mask[top:topself.base_size, left:leftself.base_size] else: img cv2.resize(img, (self.base_size, self.base_size), interpolationcv2.INTER_LINEAR) mask cv2.resize(mask, (self.base_size, self.base_size), interpolationcv2.INTER_NEAREST) # 归一化并转 tensor img img.astype(np.float32) / 255.0 img torch.from_numpy(img).permute(2, 0, 1) mask torch.from_numpy(mask).long() return img, mask这段代码里scale_range控制缩放范围base_size是最终网络输入的尺寸。注意裁剪那一步如果缩放后尺寸大于 base_size就随机裁一块如果小于就再 resize 回去。这样保证每个 batch 的输入尺寸一致不会因为动态尺寸导致 CUDA 报错。标签用INTER_NEAREST是血泪经验用线性插值会让标签出现 0.5 这种值交叉熵直接报错。3.3 多尺度推理与 TTA 的收益边界训练时多尺度推理时也可以用多尺度做 TTA测试时增强。具体做法是把验证集图像缩放到 0.75、1.0、1.25 三个尺度分别推理后把概率图 resize 回原尺寸再平均。我在子宫颈数据上试过TTA 能把 Dice 再提 0.01 到 0.015但推理时间变成三倍。如果你的场景对实时性有要求比如要集成到显微镜实时分析里TTA 就不划算。我一般只在最终提交或者离线分析时开 TTA日常验证不开。4. 多类别分割的标签映射与损失函数选择4.1 二分类和多类别的本质区别标题里写的是「多类别分割子宫颈细胞核分割2分类」这里有个容易混淆的点2 分类指的是前景和背景两类但输出通道可以是 2也可以是 1。如果用 1 个输出通道加 Sigmoid就是二分类的常规做法如果用 2 个输出通道加 Softmax就是多类别框架下的二分类。两者在数学上等价但工程上差别很大。用 Softmax 的好处是后面扩展到多类别比如细胞核、细胞质、背景三类时不用改网络结构只需要改num_classes。我一般直接用 2 通道 Softmax省得以后迁移麻烦。标签映射上原始标注可能是 0 和 255也可能是 0 和 1还可能是彩色 RGB。我见过最坑的是标注里背景是 0细胞核是 255但中间还有一圈 128 的过渡带。这种数据直接送进损失函数模型会学出一个第三类但你的num_classes设的是 2就会报标签越界。所以拿到数据第一件事是np.unique(mask)看清楚到底有几个值。4.2 Dice Loss 和 CrossEntropy 的组合权重细胞核分割里前景像素占比通常不到 10%纯交叉熵会被背景主导模型倾向于全预测背景。我一般用 Dice Loss 加 CrossEntropy 的组合权重各 0.5。Dice Loss 对类别不平衡不敏感但训练初期梯度不稳定所以加上交叉熵做平滑。import torch import torch.nn as nn import torch.nn.functional as F class DiceCE Loss(nn.Module): def __init__(self, num_classes2, dice_weight0.5): super().__init__() self.num_classes num_classes self.dice_weight dice_weight self.ce nn.CrossEntropyLoss() def forward(self, logits, targets): # 交叉熵部分 ce_loss self.ce(logits, targets) # Dice 部分 probs F.softmax(logits, dim1) targets_onehot F.one_hot(targets, self.num_classes).permute(0, 3, 1, 2).float() dice_loss 0.0 for cls in range(self.num_classes): p probs[:, cls] t targets_onehot[:, cls] intersection (p * t).sum() dice_loss 1 - (2 * intersection 1e-6) / (p.sum() t.sum() 1e-6) dice_loss / self.num_classes return self.dice_weight * dice_loss (1 - self.dice_weight) * ce_loss这段代码里1e-6是平滑项防止分母为零。dice_weight0.5是我在子宫颈数据上试出来的平衡点如果你数据里前景特别少可以调到 0.7。注意targets必须是long类型one_hot之后要permute成NCHW才能和probs对齐。我见过有人忘了permute结果 Dice 算出来一直是 0.99其实是维度广播错了。4.3 类别权重与采样策略如果某些类别特别少比如多类别里的某个稀有细胞核类型可以在 CrossEntropy 里加weight参数。但我不建议一上来就加先看混淆矩阵确认是真的类别不平衡再动手。另一个策略是在 Dataset 里做加权采样让稀有类别所在的图像被抽到的概率更高。这两种方法选一个就行同时用容易过拟合。5. 训练避坑从损失不下降到验证集 Dice 虚高5.1 损失不下降先查标签范围现象训练了几个 epoch损失一直在 0.69 附近震荡不下降。原因标签里有超出num_classes的值比如背景是 0细胞核是 255但num_classes2CrossEntropy 直接忽略越界标签或者报错。解决在 Dataset 里加一句mask np.clip(mask, 0, num_classes - 1)或者用np.unique确认标签值。我一般会在训练前跑一遍全量数据的标签统计打印每个值的像素占比。5.2 验证集 Dice 虚高可能是数据泄漏现象验证集 Dice 0.95但拿新数据一测只有 0.6。原因训练集和验证集里来自同一张涂片的图像被分到了两边细胞核形态高度相似模型相当于背答案。解决按涂片 ID 划分数据集同一张涂片的所有视野只能出现在训练集或验证集之一。这个坑在医学图像里非常常见尤其是数据量少的时候。5.3 多尺度训练后显存爆炸现象加了多尺度训练batch size 没变但显存占用涨了。原因缩放后图像尺寸变大如果没做随机裁剪输入网络的尺寸就是动态的PyTorch 会按最大尺寸分配显存。解决确保__getitem__最后输出的尺寸是固定的base_size裁剪那一步不能省。另外可以把base_size从 512 降到 256显存能省一半Dice 掉不到 0.01。5.4 预训练权重加载后 loss 变成 NaN现象加载 Resnet 预训练权重后第一个 epoch 就 NaN。原因预训练权重的 BN 层统计量是 ImageNet 的和医学图像的分布差异大加上学习率设成了 1e-3直接梯度爆炸。解决加载预训练后把编码器的学习率设成解码器的十分之一或者先冻结编码器跑几个 epoch。我一般用param_groups给编码器和解码器分别设学习率。5.5 多类别 Softmax 输出全预测背景现象训练集 loss 正常下降但验证集里所有像素都被预测成背景。原因前景占比太低交叉熵被背景主导。解决换 Dice Loss 或者提高 Dice 权重同时在验证时看前景的 IoU不要只看整体准确率。整体准确率在类别不平衡时没有意义全预测背景也能到 90%。6. 把 Dice 从 0.81 推到 0.86三个我常用的后处理技巧第一个技巧是连通域过滤。模型输出的概率图二值化后往往有一些孤立的噪点面积很小。我一般用cv2.connectedComponents把面积小于 50 像素的连通域去掉这一步在子宫颈数据上能提 0.01 到 0.02 的 Dice。注意面积阈值要根据你的分辨率调不要照搬。第二个技巧是条件随机场CRF或者简单的形态学闭运算。CRF 对边界平滑效果很好但速度慢我一般只在离线分析时用。日常用cv2.morphologyEx做一次闭运算kernel 用 3x3能把细胞核内部的空洞填上Dice 提 0.005 左右。第三个技巧是阈值搜索。模型输出的概率图默认用 0.5 做阈值但 0.5 不一定最优。我会在验证集上从 0.3 到 0.7 步长 0.05 搜一遍选 Dice 最高的阈值。这个操作成本很低但经常能捡到 0.01 的收益。下面是一个阈值搜索的代码片段import numpy as np from sklearn.metrics import f1_score def search_threshold(probs, masks): best_th, best_dice 0.5, 0.0 for th in np.arange(0.3, 0.71, 0.05): preds (probs th).astype(np.uint8) dice f1_score(masks.flatten(), preds.flatten(), averagebinary) if dice best_dice: best_dice dice best_th th return best_th, best_dice这段代码里probs是模型输出的前景概率masks是真实标签。f1_score在二分类下等价于 Dice。搜出来的阈值可以直接用在测试集上但要注意如果验证集和测试集分布差异大阈值可能会过拟合所以最好用交叉验证的方式选阈值。我自己的习惯是每次训完一个新模型先跑一遍阈值搜索再跑一遍连通域过滤最后看 Dice 和 IoU 两个指标。如果 Dice 涨了但 IoU 没涨说明边界预测变好了但内部空洞多了这时候要回去看损失函数。这些后处理技巧不改变模型本身但能让你在同样的模型上多拿几个点性价比很高。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网