ARTICLE DETAIL

资讯详情

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

Unet+Resnet多尺度训练:子宫颈细胞核分割实战

Unet+Resnet多尺度训练:子宫颈细胞核分割实战 简介这是一份面向深度学习图像分割初学者与进阶者的实战项目资源以Unet为主干网络、Resnet为backbone聚焦子宫颈细胞核的二分类分割任务同时演示多尺度训练与多类别分割的完整实现思路。压缩包共804个文件以jpg与png图像数据为主辅以py训练与推理脚本、pyc缓存、xml标注、txt日志及pth权重文件整体约113.33MB数据集、代码与训练好的权重一应俱全经测试可直接运行。项目仅训练50个epoch全局像素准确率即达0.89miou为0.82扩大训练轮数后性能仍有提升空间。训练脚本会自动将数据随机缩放至0.5至1.5倍实现多尺度增强utils中的compute_gray函数负责保存mask灰度值并自动定义UNET输出通道学习率采用cos衰减损失与iou曲线、各类别iou、recall、precision及全局准确率均可在run目录与训练日志中查看。推理时只需将待测图像放入inference目录并运行predict脚本无需额外参数。目前已有228人学习适合希望快速上手分割项目、理解多尺度训练与评估流程的读者参考。1. 从一张细胞核涂片说起UnetResnet 做子宫颈细胞核分割到底难在哪子宫颈细胞核分割这个任务我第一次接触时以为是个常规的语义分割练手项目真正跑起来才发现坑比想象中多。一张巴氏涂片里细胞核的边界经常和细胞质、炎症细胞、黏液背景糊在一起染色深浅不一同一个视野里既有几十像素的小核也有占据大半个画面的异常核。更麻烦的是类别极不平衡——背景像素动辄占 90% 以上如果直接拿交叉熵去训模型很快学会「全预测成背景」也能拿到很高的准确率但 IoU 惨不忍睹。这个标题里的技术组合其实指向一个很明确的落地场景用 Unet 做分割骨架把编码器换成 Resnet 预训练权重再叠加多尺度训练来应对细胞核尺寸跨度大的问题最终输出背景/细胞核的二分类掩码。它适合两类人一类是医学图像方向的入门者想找一个数据量不大、任务定义清晰的分割项目把整套流程跑通另一类是做病理 AI 的工程师需要一个能快速验证预处理、损失函数、后处理策略的基线。我下面讲的这套方案核心目标不是刷某个榜单的 SOTA而是让你在一台单卡机器上用几百到几千张涂片稳定复现出一个 IoU 能到 0.8 以上的细胞核分割模型并且清楚每个参数为什么这么设、哪里最容易翻车。Unet 的跳跃连接负责找回细胞核的精细边界Resnet 预训练负责在数据有限时稳住特征提取多尺度训练负责让模型对大小核都敏感——这三件事缺一个效果都会明显掉。2. UnetResnet 的骨架怎么搭编码器替换与解码器对齐2.1 为什么用 Resnet 换掉 Unet 原生编码器原生 Unet 的编码器是几个 3x3 卷积堆出来的从头训练在小数据集上很容易过拟合而且浅层特征表达能力有限。Resnet 的残差结构在 ImageNet 上预训练过浅层已经能提取边缘、纹理这类通用特征迁移到细胞核分割上收敛速度和最终精度都比从头训要好。我一般用 Resnet34 起步显存吃紧就换 Resnet18数据量上千张、显存够就上 Resnet50。替换时要注意通道对齐。Resnet 的 stage 输出通道分别是 64、64、128、256、512以 Resnet34 为例而原生 Unet 解码器习惯的通道是 512、256、128、64、32。跳跃连接必须把编码器对应 stage 的特征图接到解码器同分辨率的层上通道数不一致时用一个 1x1 卷积压到解码器期望的通道数否则拼接会直接报维度错误。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 做编码器去掉最后的全连接和平均池化 backbone models.resnet34(weightsIMAGENET1K_V1 if pretrained else None) self.enc0 nn.Sequential(backbone.conv1, backbone.bn1, backbone.relu, backbone.maxpool) # /4 self.enc1 backbone.layer1 # /4, 64 通道 self.enc2 backbone.layer2 # /8, 128 通道 self.enc3 backbone.layer3 # /16, 256 通道 self.enc4 backbone.layer4 # /32, 512 通道 # 解码器每层先上采样再和编码器特征拼接最后两次 3x3 卷积 self.up4 nn.ConvTranspose2d(512, 256, 2, stride2) self.dec4 self._block(256 256, 256) self.up3 nn.ConvTranspose2d(256, 128, 2, stride2) self.dec3 self._block(128 128, 128) self.up2 nn.ConvTranspose2d(128, 64, 2, stride2) self.dec2 self._block(64 64, 64) self.up1 nn.ConvTranspose2d(64, 32, 2, stride2) self.dec1 self._block(32 64, 32) # enc0 输出 64 通道 self.head nn.Conv2d(32, num_classes, 1) def _block(self, in_ch, out_ch): return nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) def forward(self, x): e0 self.enc0(x) # /4 e1 self.enc1(e0) # /4 e2 self.enc2(e1) # /8 e3 self.enc3(e2) # /16 e4 self.enc4(e3) # /32 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.head(d1)这段代码的关键点有三个。第一enc0把 conv1、bn1、relu、maxpool 串起来输出是 1/4 分辨率、64 通道正好对应解码器最后一层拼接。第二每个解码块先ConvTranspose2d上采样两倍再和同分辨率编码特征cat通道数相加后进_block。第三head用 1x1 卷积把 32 通道映射到num_classes2输出和输入同分辨率。参数上num_classes二分类就是 2别写成 1 再用 Sigmoid那样损失函数和评估都要跟着改容易乱。pretrainedTrue时第一次跑会下载权重离线环境记得提前把权重文件放到~/.cache/torch/hub/checkpoints/。如果显存不够把Resnet34换成Resnet18解码器通道同步减半即可。2.2 跳跃连接的对齐细节与常见维度错误跳跃连接看着简单实际最容易在通道和分辨率上翻车。Resnet 的layer1输出 stride 是 1分辨率还是 1/4但enc0经过 maxpool 后也是 1/4两者分辨率一致、通道都是 64所以dec1拼接的是up1(d2)的 32 通道和e0的 64 通道_block输入写 96。如果你把enc0里的 maxpool 去掉分辨率就变成 1/2和up1的 1/4 对不上cat会直接报错。另一个坑是输入尺寸。Unet 要求输入能被 32 整除经过 5 次下采样细胞核涂片原图往往是 1000x1000 这种不规则尺寸。我一般统一 resize 到 512x512 或者 1024x1024或者用 padding 补到最近的 32 倍数。resize 会改变细胞核的绝对大小这对多尺度训练反而是好事后面会讲。提示调试阶段先拿一个 batch 走一遍 forward打印每层特征图尺寸确认e0到e4和d1到d4的分辨率、通道完全对得上再开始训练。这一步能省掉后面几小时的报错排查。3. 多尺度训练怎么落地从数据增强到损失函数3.1 多尺度训练的两种实现路径细胞核尺寸跨度大固定输入尺度会让模型偏向某一类尺寸。多尺度训练的核心思路是让同一个 batch 或不同 iteration 看到不同缩放比例的图像迫使模型学习尺度不变的特征。常见做法有两种。第一种是随机缩放增强每个样本在[0.5, 2.0]之间随机选一个缩放因子缩放后再随机裁剪到固定尺寸比如 512x512。这种做法实现简单和常规数据增强管线无缝集成缺点是每个 batch 内尺度不一致BatchNorm 的统计量会有点抖。第二种是多尺度 batch 拼接把不同尺度的图像分别 resize 到同一尺寸再拼成一个 batch或者用金字塔式的多分支输入。这种做法更接近「多尺度」的字面意思但实现复杂显存占用也高。我一般先用第一种效果不够再考虑第二种。import random import numpy as np import torch from torch.utils.data import Dataset import cv2 class NucleiDataset(Dataset): def __init__(self, img_paths, mask_paths, base_size512, scale_range(0.5, 2.0)): self.img_paths img_paths self.mask_paths mask_paths self.base_size base_size self.scale_range scale_range def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img cv2.imread(self.img_paths[idx]) # BGR img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask cv2.imread(self.mask_paths[idx], 0) # 灰度0/255 mask (mask 127).astype(np.uint8) # 二值化到 0/1 # 随机缩放模拟不同大小的细胞核 scale random.uniform(*self.scale_range) h, w img.shape[:2] nh, nw int(h * scale), int(w * scale) img cv2.resize(img, (nw, nh), interpolationcv2.INTER_LINEAR) mask cv2.resize(mask, (nw, nh), interpolationcv2.INTER_NEAREST) # 随机裁剪到 base_size不足则 padding if nh self.base_size or nw self.base_size: pad_h max(0, self.base_size - nh) pad_w max(0, self.base_size - nw) img cv2.copyMakeBorder(img, 0, pad_h, 0, pad_w, cv2.BORDER_CONSTANT, value(0, 0, 0)) mask cv2.copyMakeBorder(mask, 0, pad_h, 0, pad_w, cv2.BORDER_CONSTANT, value0) nh, nw img.shape[:2] top random.randint(0, nh - self.base_size) left random.randint(0, nw - self.base_size) img img[top:top self.base_size, left:left self.base_size] mask mask[top:top self.base_size, left:left self.base_size] # 归一化 转 tensor img img.astype(np.float32) / 255.0 img (img - np.array([0.485, 0.456, 0.406])) / np.array([0.229, 0.224, 0.225]) img torch.from_numpy(img).permute(2, 0, 1).float() mask torch.from_numpy(mask).long() return img, maskscale_range是核心参数。细胞核直径如果分布在 10 到 100 像素(0.5, 2.0)基本够用如果小核特别多可以放宽到(0.3, 2.5)但太小会让小核缩到几个像素标注噪声被放大。base_size建议 512 起步1024 对显存要求翻倍但边界更精细。INTER_NEAREST用于 mask 是必须的用线性插值会造出 0.5 这种非法类别值。3.2 损失函数与类别不平衡的处理二分类分割里背景占绝对多数交叉熵会被背景主导。我一般用 Dice Loss 和 BCE 的组合Dice 直接优化重叠度对前景少的情况更鲁棒。如果细胞核之间粘连严重可以再加一个边界加权但那是进阶操作先把基础组合调稳。import torch.nn.functional as F def dice_loss(logits, targets, eps1e-6): # logits: [B, 2, H, W], targets: [B, H, W] probs F.softmax(logits, dim1)[:, 1] # 取前景概率 targets targets.float() intersection (probs * targets).sum(dim(1, 2)) union probs.sum(dim(1, 2)) targets.sum(dim(1, 2)) dice (2 * intersection eps) / (union eps) return 1 - dice.mean() def combined_loss(logits, targets, bce_weight0.5): bce F.cross_entropy(logits, targets) dice dice_loss(logits, targets) return bce_weight * bce (1 - bce_weight) * dicebce_weight我一般从 0.5 开始调。如果验证集上模型倾向于漏检小核把 Dice 权重加大到 0.7如果边界糊、误检多把 BCE 权重提到 0.6。Dice 的eps别省前景全空时会出现 0/0加个 1e-6 能避免 NaN。注意多尺度训练和损失函数要一起看。缩放后小核可能只剩几个像素Dice 对这类样本的梯度贡献很小如果发现小核 IoU 一直上不去可以在采样时对小核区域做 oversampling或者用带权重的 BCE 给前景像素更高权重。4. 训练、验证与推理的完整链路4.1 训练循环与关键超参训练循环本身不复杂但有几个参数直接决定能不能收敛。优化器用 AdamW学习率 1e-4 起步weight decay 1e-4。预训练编码器的学习率可以设小一点比如 1e-5解码器用 1e-4这种分层学习率在小数据集上更稳。batch size 根据显存来512x512 输入、Resnet34 编码器单张 12G 卡大概能跑 8 到 16。from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR def train_one_epoch(model, loader, optimizer, device): model.train() total_loss 0 for imgs, masks in loader: imgs, masks imgs.to(device), masks.to(device) optimizer.zero_grad() logits model(imgs) loss combined_loss(logits, masks) loss.backward() # 梯度裁剪防止多尺度样本导致的梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() return total_loss / len(loader) # 分层学习率 backbone_params list(model.enc0.parameters()) list(model.enc1.parameters()) \ list(model.enc2.parameters()) list(model.enc3.parameters()) \ list(model.enc4.parameters()) decoder_params [p for n, p in model.named_parameters() if not n.startswith(enc)] optimizer AdamW([ {params: backbone_params, lr: 1e-5}, {params: decoder_params, lr: 1e-4}, ], weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max50, eta_min1e-6)clip_grad_norm_的max_norm5.0是我踩过坑之后加的。多尺度训练里偶尔会碰到缩放后前景极少的样本梯度会突然变大不裁剪的话 loss 会直接飙到 NaN。CosineAnnealingLR的T_max设成总 epoch 数让学习率平滑降到接近 0。4.2 验证指标与推理后处理验证阶段别只看 loss要看 IoU 和 Dice。二分类的 IoU 就是前景的交并比计算时把预测的 argmax 结果和 mask 比。推理时输出是每个像素的类别概率取 argmax 得到 0/1 掩码。如果细胞核粘连严重可以加一步形态学开运算或者分水岭但那是后处理先把原始输出调好。torch.no_grad() def evaluate(model, loader, device): model.eval() iou_sum, dice_sum, n 0, 0, 0 for imgs, masks in loader: imgs, masks imgs.to(device), masks.to(device) logits model(imgs) preds logits.argmax(dim1) # [B, H, W] for p, t in zip(preds, masks): p, t p.bool(), t.bool() inter (p t).sum().item() union (p | t).sum().item() iou_sum inter / (union 1e-6) dice_sum 2 * inter / (p.sum().item() t.sum().item() 1e-6) n 1 return iou_sum / n, dice_sum / n推理时如果原图不是 32 的倍数先 padding 再推理最后裁回原尺寸。多尺度推理TTA也能涨点把图缩放到 0.75、1.0、1.25 分别推理概率图平均后再 argmax一般能涨 1 到 2 个点 IoU代价是推理时间翻三倍。5. 避坑与排查细胞核分割里最容易翻车的五件事5.1 现象训练 loss 正常下降但验证 IoU 一直卡在 0.3 以下原因通常是标签格式不对。很多公开数据集的 mask 是 0/255如果没做二值化直接当类别标签cross_entropy会收到 255 这种非法类别梯度直接乱掉。另一个可能是图像和 mask 的 resize 用了同一种插值mask 被插值出中间值。解决在 Dataset 里强制mask (mask 127).astype(np.uint8)mask 的 resize 一律用INTER_NEAREST。训练前抽一个 batch 可视化确认 mask 只有 0 和 1。5.2 现象模型把所有像素预测成背景IoU 为 0这是类别不平衡的典型表现。交叉熵被背景主导模型发现全预测背景就能拿到很低的 loss。解决换成 BCEDice 组合或者给前景像素加权重。如果已经用了组合损失还是这样检查 Dice 的eps是不是太大或者前景概率取错了通道。5.3 现象多尺度训练开启后 loss 震荡剧烈偶尔 NaN原因缩放后某些样本前景几乎消失Dice 的分母接近 0加上梯度裁剪没开或者阈值太大。解决eps设 1e-6开clip_grad_norm_max_norm设 5.0 左右。另外检查scale_range下限是不是太小0.3 以下小核会缩到 2 到 3 个像素标注噪声被放大建议下限不低于 0.5。5.4 现象推理结果边界锯齿严重细胞核边缘像被狗啃过原因上采样用了ConvTranspose2d且没有后续卷积平滑或者输入分辨率太低。解决解码器每个上采样后接两次 3x3 卷积代码里的_block已经做了推理时用 1024 输入或者加 TTA。如果还不行在损失里加边界加权但优先检查输入分辨率。5.5 现象换到自己的数据集后模型完全训不动原因染色差异、扫描仪差异导致图像分布和预训练数据差太远。解决先做颜色归一化比如 Reinhard 或者 Macenko再检查标注质量。如果数据量少于 200 张冻结编码器前几个 stage只训解码器和后两个 stage等 loss 稳了再解冻。6. 把 IoU 从 0.82 推到 0.88 的几个具体技巧基础方案跑通后想再往上走我一般按这个顺序试。第一TTA 多尺度推理0.75/1.0/1.25 三尺度概率平均几乎无成本涨 1 到 2 个点唯一代价是推理时间。第二把 Resnet34 换成 Resnet50 或者加一个轻量注意力模块但要注意数据量少于 500 张时换大 backbone 很容易过拟合验证集 IoU 反而掉。第三损失函数里加边界加权用 mask 的形态学梯度生成边界带给边界像素更高权重对粘连细胞核的分离效果明显。技巧预期涨幅代价适用条件多尺度 TTA1~2 IoU推理 x3通用优先做换 Resnet501~3 IoU显存 x1.5数据 500 张边界加权损失1~2 IoU需生成边界图细胞核粘连多颜色归一化2~5 IoU预处理耗时跨中心数据分水岭后处理1~2 IoU调参麻烦粘连严重验证方法上别只看整体 IoU按细胞核尺寸分桶统计。我一般把验证集的核按面积分成小200 像素、中、大三档分别算 IoU。如果小核 IoU 明显低就回去调scale_range下限和采样策略如果大核低检查感受野够不够。这个分桶分析比看一个总数有用得多。最后说个我自己的习惯每次改完参数先跑 5 个 epoch 看验证 IoU 的趋势别一上来就训 100 轮。细胞核分割的收敛信号在前 10 轮就很明显了趋势不对就赶紧回头查数据和损失别硬等。这套 UnetResnet 加多尺度训练的框架我在几个不同的涂片数据集上都跑过基础配置稳定在 0.82 到 0.85加上 TTA 和边界加权能到 0.88 左右。真正决定上限的不是网络结构是标注质量和预处理的一致性。希望帮到你。本文还有配套的精品资源点击获取
返回列表