ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Unet+Resnet实现宫颈细胞核分割:多尺度训练与工程实践

2026/9/10 22:26:42 拓冰建站 浏览量
Unet+Resnet实现宫颈细胞核分割:多尺度训练与工程实践 简介一套完整的深度学习分割实战资源面向希望系统掌握Unet与Resnet结合、多尺度训练及多类别分割的开发者项目以子宫颈细胞核分割为具体场景提供数据集、Python代码与预训练权重经测试可直接运行。代码实现了多尺度训练策略输入数据随机缩放至设定尺寸的0.51.5倍以提升泛化能力utils中的compute_gray函数可将mask灰度值保存至txt并自动配置Unet输出通道便于扩展多分类任务训练采用cos学习率衰减仅50个epoch即达到全局像素准确率0.89、miou0.72增大训练轮次后性能还有提升空间。run_results中保存了训练/测试损失与IoU曲线日志包含各类别iou、recall、precision等指标推理时只需将图片放入inference目录运行predict脚本README文档对新手上手十分友好。资源共804个文件以JPG/PNG图像、Python源码、txt、xml及pth权重为主压缩包约113MB当前已有228人学习适合作为快速上手的完整参考项目。1. UnetResnet 处理子宫颈细胞核分割的三个关键判断宫颈癌筛查中细胞学涂片的自动分析长期卡在同一个环节细胞核边缘模糊、染色随制片批次波动、细胞簇重叠后边界难以辨认。拿到 UnetResnet 这个组合做分割项目第一件事不是搭模型而是确认任务边界。子宫颈细胞核分割在多数数据集上被定义为二分类任务——像素级区分细胞核与背景多类别分割作为模型结构和训练管线保留的能力方便后续把细胞质、腺上皮等纳入同一个框架。落地路径比较固定构造带标注训练集、做多尺度裁剪与增强、搭建 Resnet 编码器的 Unet 结构、配上前景占比合适的损失函数。这套方法能直接迁移到其他组织切片的核分割场景。2. 数据准备宫颈细胞核掩码读取与多尺度裁剪的实现2.1 标注掩码的类别映射把多值标注折叠成 0/1细胞核分割数据集的标注通常以单通道 PNG 给出背景像素值为 0细胞核区域可能是 255也可能是 1 或 128。标注工具不同掩码约定差异很大。统一二分类标签时最常见的做法是把所有非零像素折叠为 1只保留前景和背景两个语义。import cv2 import numpy as np def mask_to_binary(mask_path): # 单通道读取避免把索引色 PNG 读成三通道彩色 mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) # 打印像素分布确认数据集的标注约定 unique_vals, counts np.unique(mask, return_countsTrue) print(mask pixel values:, dict(zip(unique_vals.tolist(), counts.tolist()))) binary np.zeros_like(mask, dtypenp.uint8) binary[mask 0] 1 return binary像素分布打印值得在项目初期跑一次。很多公开数据集里细胞核标注是 255少数是 1还有一种情况是掩码里同时存在 1、2、3分别对应不同结构但训练时只需要核与非核。cv2.IMREAD_GRAYSCALE保留单通道避免读取 8 位索引色 PNG 时得到三通道 RGB 数据。如果需要扩展到多类别这个函数要改成显式映射不能继续用mask 0我在第 5 章会给出对应写法。掩码的另一个常见问题是边缘出现 1~2 像素的孤立噪点或者细胞簇被整体标成一个连通域。可以用很小的开运算去除噪点但只对训练标签操作不对原图操作否则边缘精度会受影响。注意mask 0这种写法只适合二分类定稿版本。项目一旦决定做多类别必须先理清标注文件里每一个像素值的语义再决定是合并还是保留。2.2 多尺度裁剪解决细胞核尺寸差异的预处理方案单张玻片视野里细胞核直径可能只占 20~80 像素在放大视野中同一个核能占到 200 像素以上。模型如果一直在单一尺度上看到目标泛化时遇到尺寸突变会明显掉点。多尺度裁剪把缩放和位置采样耦合在一起每个训练样本都携带不同程度的缩放扰动。def multi_scale_crop(image, mask, crop_size512, scale_min0.7, scale_max1.3): 随机缩放后取固定尺寸子图。 掩码缩放必须用最近邻插值避免边界出现 0~1 之间的中间值。 h, w image.shape[:2] scale np.random.uniform(scale_min, scale_max) new_h, new_w int(round(h * scale)), int(round(w * scale)) resized_img cv2.resize(image, (new_w, new_h), interpolationcv2.INTER_LINEAR) resized_mask cv2.resize(mask, (new_w, new_h), interpolationcv2.INTER_NEAREST) # 随机位置裁剪 top np.random.randint(0, new_h - crop_size) if new_h crop_size else 0 left np.random.randint(0, new_w - crop_size) if new_w crop_size else 0 img_crop resized_img[top:top crop_size, left:left crop_size] msk_crop resized_mask[top:top crop_size, left:left crop_size] # 缩放后仍小于裁剪窗口时采用边缘补零 if img_crop.shape[0] crop_size or img_crop.shape[1] crop_size: pad_h max(0, crop_size - img_crop.shape[0]) pad_w max(0, crop_size - img_crop.shape[1]) img_crop cv2.copyMakeBorder(img_crop, 0, pad_h, 0, pad_w, cv2.BORDER_CONSTANT, value0) msk_crop cv2.copyMakeBorder(msk_crop, 0, pad_h, 0, pad_w, cv2.BORDER_CONSTANT, value0) return img_crop, msk_crop图像用线性插值是因为染色图像本身是连续渐变标签用最近邻插值是为了保证像素值仍是离散整数。随机裁剪的起始点覆盖整张图即可不需要额外做密集滑动采样否则训练分布会被重复内容污染。尺度范围可以按训练阶段调整前 30 轮用 0.7~1.3后 20 轮收窄到 0.85~1.15让网络先看大范围尺度变化后期专注边缘细化。2.3 数据增强组合与像素不平衡的处理增强操作参数建议对分割训练的意义随机 90 度旋转概率 0.5细胞核方向随机增强旋转不变性水平/垂直翻转概率 0.5常规几何增强随机亮度/对比度brightness_limit0.2, contrast_limit0.2应对染色深浅差异染色归一化扰动概率 0.3模拟不同制片批次的染色偏差ElasticTransformalpha1, sigma20模拟细胞在制片过程中的形变染色归一化扰动这里值得多说一句。病理图像的色调偏移主要是染液配比差异造成的RGB 空间里调亮度和对比度只能模拟明暗变化模拟不了染色整体偏蓝或偏紫。做法是先把图像分离到染色通道做轻度偏移后再逆变换回 RGB效果比 HSV 空间的色相调节更接近真实污染。很多分割项目线上掉点问题不在模型而在增强阶段没有覆盖染色分布漂移。像素不平衡方面核占整张图像的比例平均在 10% 左右极端情况下低于 3%。直接用交叉熵网络会把所有像素判成背景来压低 loss。我习惯在数据读取阶段做硬采样裁剪后如果子图完全没有核按 20% 的概率保留其余重新采。这种做法的好处是不改变模型输出语义比在损失函数里调整权重更直观而且每个 epoch 都能保证出现足量正样本。3. UnetResnet 编码器-解码器实现结构拆分与预训练加载3.1 Resnet 编码器的特征分层与预训练优势Unet 的编码器负责从输入图像提取多层级特征解码器负责把低分辨率语义逐步还原成逐像素预测。细胞核分割里浅层特征包含边缘、纹理和染色信息深层特征判断区域是否属于核。Resnet 在这里的主要作用不是把网络堆得更深而是通过残差连接让梯度在编码器内部顺畅回传深层训练不容易退化。使用带 ImageNet 预训练的 Resnet 编码器是常见做法直接加载 torchvision 权重即可。宫颈细胞核图像与 ImageNet 分布差异很大但预训练权重依然有效因为前几层卷积捕捉的是边缘、角点和颜色斑块这类通用视觉结构。真正需要重新适应的是深层语义layer4 的权重可以保留初始值但不要冻结让它在细胞核数据里重新适应。如果从头训练 Resnet34 编码器在数据量不超过一万张裁剪图时收敛速度明显偏慢而且容易陷入局部最优。预训练权重相当于把前几层的特征提取能力提前准备好项目重点就转移到了解码器的跳连和多尺度训练上。3.2 Pytorch 实现 Unet 解码器和跳跃连接import torch import torch.nn as nn import torch.nn.functional as F from torchvision.models import resnet34, ResNet34_Weights class ResnetUnet(nn.Module): ResNet34 编码器 Unet 解码器。 num_classes2 表示输出背景和细胞核两个通道。 def __init__(self, num_classes2, pretrainedTrue): super().__init__() base resnet34(weightsResNet34_Weights.IMAGENET1K_V1 if pretrained else None) # 编码器各阶段按输出尺寸和通道数拆分 self.enc1 nn.Sequential(base.conv1, base.bn1, base.relu) # 1/264 self.maxpool base.maxpool self.enc2 base.layer1 # 1/464 self.enc3 base.layer2 # 1/8128 self.enc4 base.layer3 # 1/16256 self.enc5 base.layer4 # 1/32512 # 解码器上采样后与对应跳跃连接拼接 self.up4 nn.ConvTranspose2d(512, 256, kernel_size2, stride2) self.dec4 self._dec_block(256 256, 256) self.up3 nn.ConvTranspose2d(256, 128, kernel_size2, stride2) self.dec3 self._dec_block(128 128, 128) self.up2 nn.ConvTranspose2d(128, 64, kernel_size2, stride2) self.dec2 self._dec_block(64 64, 64) self.up1 nn.ConvTranspose2d(64, 64, kernel_size2, stride2) self.dec1 self._dec_block(64 64, 64) self.head nn.Conv2d(64, num_classes, kernel_size1) def _dec_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): e1 self.enc1(x) e2 self.enc2(self.maxpool(e1)) e3 self.enc3(e2) e4 self.enc4(e3) e5 self.enc5(e4) d4 self.dec4(torch.cat([self.up4(e5), e4], dim1)) d3 self.dec3(torch.cat([self.up3(d4), e3], dim1)) d2 self.dec2(torch.cat([self.up2(d3), e2], dim1)) d1 self.dec1(torch.cat([self.up1(d2), e1], dim1)) out self.head(d1) return F.interpolate(out, sizex.shape[-2:], modebilinear, align_cornersFalse)torch.cat沿通道维拼接要求两个特征图的宽高严格一致。编码器每经过一次下采样尺寸减半解码器用相同步长的转置卷积还原拼接点都在尺寸恰好的层级上。如果输入尺寸不是 32 的倍数最后一层特征图会出现奇数维度上采样后拼接报错。实际项目里把输入统一 resize 到 512 或 384 即可省去很多对齐问题。注意forward 尾部把输出直接插值回原始输入尺寸这样损失函数计算不需要感知模型内部的缩放关系。代价是多消耗一些显存选择性按需裁剪。3.3 预训练权重的冻结策略与输入归一化加载预训练权重后不建议把整个编码器冻结到底。浅层特征通用性强冻结早期阶段能减少梯度冲突深层语义特征需要适配新的目标分布必须参与训练。常见的做法是先冻结 enc1 和 enc2 训练 10 轮待解码器稳定后再解冻全部参数并用较小学习率微调。训练阶段可训练参数学习率第 1~10 轮冻结 enc1/enc2其余训练1e-4第 11 轮起全部解冻编码器 5e-5解码器 1e-4输入归一化必须对齐 ImageNet 的均值和方差否则预训练权重的激活分布与训练时不一致冻结策略也失去意义。虽然宫颈细胞核图像与自然图像差异大但颜色空间处理依然按 RGB 三通道输入因为染色信息本身是有效特征不适合转成灰度。4. 多尺度训练的参数配置损失函数、优化器与排错4.1 多尺度训练的具体实现批内尺度一致还是逐样本随机多尺度训练有两种实现路径。第一种是每个迭代内整个 batch 使用同一个尺度尺度在迭代之间随机变化第二种是 batch 内部每个样本尺度都不同。实际工程里多数走第一种原因有三个批量归一化统计量稳定、显存分配可控、训练曲线容易监控。逐样本随机尺度只在数据规模很大且显存充裕时推荐。import random def train_epoch(model, dataloader, optimizer, device, target_size512, scale_range(0.75, 1.25)): model.train() for images, masks in dataloader: # 每个迭代随机选一个尺度 scale random.uniform(*scale_range) scaled_size int(target_size * scale // 32 * 32) images F.interpolate(images, size(scaled_size, scaled_size), modebilinear, align_cornersFalse) masks F.interpolate(masks.unsqueeze(1).float(), size(scaled_size, scaled_size), modenearest).squeeze(1) # 再次插值回固定尺寸保证模型输入维度不变 images F.interpolate(images, size(target_size, target_size), modebilinear, align_cornersFalse) masks F.interpolate(masks.unsqueeze(1).float(), size(target_size, target_size), modenearest).squeeze(1) images images.to(device) masks masks.long().to(device) outputs model(images) loss F.cross_entropy(outputs, masks) optimizer.zero_grad() loss.backward() optimizer.step()scaled_size通过对齐到 32 的倍数避免 Resnet 逐级下采样后出现尺寸不匹配。先缩放到随机大尺寸再回到目标尺寸相当于在内容层面制造尺度扰动同时保证每个 batch 实际输入维度恒定。两次插值都只在必要时进行比在 DataLoader 里逐图做 cv2 操作要快。如果显存允许也可以直接让模型接收scaled_size作为输入尺寸省掉第二次插值但那样需要每次前向传播前重新分配显存训练吞吐会下降。提示训练时只在推理阶段使用多尺度是常见误用。TTA 多尺度的作用是抑制输出噪声但模型没在训练期见过尺度扰动测试时突然变尺度特征分布会不一致提升幅度远不如训练验证都用多尺度。4.2 二分类分割的损失函数Dice、Focal 与 CrossEntropy 的组合二分类语义分割面临两个问题前景占比小导致类别不平衡以及细胞核边缘毛糙需要损失敏感于整体交叠程度。单独用 CrossEntropyLoss 会在前景占比低于 10% 时偏向背景单独用 Dice 在小目标上波动大收敛不稳定。把两者组合是最常用的配置。def soft_dice_loss(logits, target, smooth1.0): 二分类 Soft-Dice只对前景通道计算。 logits: (B,2,H,W)target: (B,H,W)值为 0/1。 B, C, H, W logits.shape prob F.softmax(logits, dim1)[:, 1, :, :] target_onehot F.one_hot(target, num_classesC).permute(0, 3, 1, 2).float() target_foreground target_onehot[:, 1, :, :] prob prob.reshape(B, -1) target_foreground target_foreground.reshape(B, -1) intersection (prob * target_foreground).sum(dim1) union prob.sum(dim1) target_foreground.sum(dim1) dice (2.0 * intersection smooth) / (union smooth) return 1.0 - dice.mean() def ce_dice_loss(logits, target, dice_weight0.5): ce F.cross_entropy(logits, target) dice soft_dice_loss(logits, target) return ce dice_weight * diceSoft-Dice 只计算前景通道是因为背景通道的 Dice 几乎恒为 1把两个通道平均会让总损失虚高前景的梯度被稀释。BCE 与 Dice 的组合在单通道输出模型中同样适用逻辑一致。Focal loss 对困难样本加权在这个场景里效果一般原因在于玻片背景中染色噪点本身容易被误判Focal 会把注意力过度分配到噪声上。4.3 训练超参数参考表与典型收敛表现参数推荐值说明输入尺寸512×512显存有限用 384×384低于 320 信息损失明显batch size8~1211GB 单卡从 8 起步多尺度训练下 batch 太小影响 BN优化器AdamWweight_decay 1e-4betas(0.9, 0.999)初始学习率1e-4解冻编码器后降到 5e-5学习率调度CosineAnnealingLRT_max 设为总轮数的一半最大轮数60~100验证 DICE 通常落在 0.86~0.92视数据难度而定验证间隔每 2 轮保存 DICE 最高的 checkpoint加入多尺度训练后训练 loss 的逐 step 波动会明显增大这是正常现象。判断模型是否收敛要看平滑后的 loss 曲线和验证 DICE 趋势。如果验证 DICE 在 0.85~0.88 区间反复震荡不上升先把尺度范围缩小到 0.85~1.15再考虑增大 batch size。5. 多类别分割扩展与验证从 2 分类到多通道的迁移路径5.1 标注映射与模型输出通道的修改点从二分类迁到多类别不需要改 Unet 主体结构只要改三处标签映射、输出通道、损失函数。下面是一个显式映射的写法class_map { 0: 0, # 背景 255: 1, # 细胞核 128: 2, # 细胞质 64: 3 # 其他结构 } def map_multiclass_mask(mask): mapped np.zeros_like(mask, dtypenp.int64) for src, dst in class_map.items(): mapped[mask src] dst return mapped模型初始化改成ResnetUnet(num_classes4)损失函数切换到nn.CrossEntropyLoss()。Soft-Dice 部分同样可以保留但要注意对每个类别分别计算后背景通道同样不应参与平均。5.2 多尺度与翻转组合推理推理阶段的多尺度与训练互补用来集成不同尺度下的预测抑制边界抖动。通常能比单尺度推理提升 1~3 个百分点的 DICE。def predict_tta(model, image, device, scales(0.75, 1.0, 1.25)): 多尺度 水平翻转组合推理输入已归一化。 model.eval() raw_h, raw_w image.shape[-2:] logits_sum None with torch.no_grad(): for s in scales: new_h int(raw_h * s) // 32 * 32 new_w int(raw_w * s) // 32 * 32 img F.interpolate(image, size(new_h, new_w), modebilinear, align_cornersFalse).to(device) out model(img) out F.interpolate(out, size(raw_h, raw_w), modebilinear, align_cornersFalse) out_flip model(torch.flip(img, dims[-1])) out_flip torch.flip(out_flip, dims[-1]) out_flip F.interpolate(out_flip, size(raw_h, raw_w), modebilinear, align_cornersFalse) logits_sum out out_flip if logits_sum is None else logits_sum out out_flip return logits_sum / (len(scales) * 2)翻转预测必须把输出再翻回来否则空间位置错位。所有尺度都在原图尺寸上累加 logits避免在低分辨率空间投票导致边缘偏移。最后取argmax得到类别索引二分类场景对前景通道做 sigmoid 后单阈值分割。5.3 分割评估指标的参考实现def compute_seg_metrics(pred, gt): pred_bool pred 0.5 gt_bool gt 0.5 eps 1e-6 tp (pred_bool gt_bool).sum() fp (pred_bool ~gt_bool).sum() fn (~pred_bool gt_bool).sum() return { iou: round(tp / (tp fp fn eps), 4), dice: round(2 * tp / (2 * tp fp fn eps), 4), precision: round(tp / (tp fp eps), 4), recall: round(tp / (tp fn eps), 4), }像素级指标之外细胞核分割项目通常还要统计实例级指标也就是连通域级别的检测率。用scipy.ndimage.label对预测图做连通域标记再和标注的核中心点匹配统计漏检率和合并率。这里要留意分割阈值对结果的影响阈值每变化 0.05连通域的合并和拆分都会明显改变报告指标时务必固定推理阈值。本文还有配套的精品资源点击获取