简介:这是一份面向深度学习图像分割初学者与进阶者的实战项目资源,以Unet为主干网络、Resnet为backbone,聚焦子宫颈细胞核的二分类分割任务,同时演示多尺度训练与多类别分割的完整实现思路。压缩包共804个文件,以jpg与png图像数据为主,辅以py训练与推理脚本、pyc缓存、xml标注、txt日志及pth权重文件,整体约113.33MB,数据集、代码与训练好的权重一应俱全,经测试可直接运行。项目仅训练50个epoch,全局像素准确率即达0.89,miou为0.82,扩大训练轮数后性能仍有提升空间。训练脚本会自动将数据随机缩放至0.5至1.5倍实现多尺度增强,utils中的compute_gray函数负责保存mask灰度值并自动定义UNET输出通道,学习率采用cos衰减,损失与iou曲线、各类别iou、recall、precision及全局准确率均可在run目录与训练日志中查看。推理时只需将待测图像放入inference目录并运行predict脚本,无需额外参数。目前已有228人学习,适合希望快速上手分割项目、理解多尺度训练与评估流程的读者参考。
1. 从一张细胞核涂片说起:Unet+Resnet 做子宫颈细胞核分割到底难在哪
子宫颈细胞核分割这个任务,我第一次接触时以为是个常规的语义分割练手项目,真正跑起来才发现坑比想象中多。一张巴氏涂片里,细胞核的边界经常和细胞质、炎症细胞、黏液背景糊在一起,染色深浅不一,同一个视野里既有几十像素的小核,也有占据大半个画面的异常核。更麻烦的是类别极不平衡——背景像素动辄占 90% 以上,如果直接拿交叉熵去训,模型很快学会「全预测成背景」也能拿到很高的准确率,但 IoU 惨不忍睹。
这个标题里的技术组合其实指向一个很明确的落地场景:用 Unet 做分割骨架,把编码器换成 Resnet 预训练权重,再叠加多尺度训练来应对细胞核尺寸跨度大的问题,最终输出背景/细胞核的二分类掩码。它适合两类人:一类是医学图像方向的入门者,想找一个数据量不大、任务定义清晰的分割项目把整套流程跑通;另一类是做病理 AI 的工程师,需要一个能快速验证预处理、损失函数、后处理策略的基线。
我下面讲的这套方案,核心目标不是刷某个榜单的 SOTA,而是让你在一台单卡机器上,用几百到几千张涂片,稳定复现出一个 IoU 能到 0.8 以上的细胞核分割模型,并且清楚每个参数为什么这么设、哪里最容易翻车。Unet 的跳跃连接负责找回细胞核的精细边界,Resnet 预训练负责在数据有限时稳住特征提取,多尺度训练负责让模型对大小核都敏感——这三件事缺一个,效果都会明显掉。
2. Unet+Resnet 的骨架怎么搭:编码器替换与解码器对齐
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_classes=2, pretrained=True): super().__init__() # 用 Resnet34 做编码器,去掉最后的全连接和平均池化 backbone = models.resnet34(weights='IMAGENET1K_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, stride=2) self.dec4 = self._block(256 + 256, 256) self.up3 = nn.ConvTranspose2d(256, 128, 2, stride=2) self.dec3 = self._block(128 + 128, 128) self.up2 = nn.ConvTranspose2d(128, 64, 2, stride=2) self.dec2 = self._block(64 + 64, 64) self.up1 = nn.ConvTranspose2d(64, 32, 2, stride=2) 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, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) 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], dim=1)) d3 = self.dec3(torch.cat([self.up3(d4), e2], dim=1)) d2 = self.dec2(torch.cat([self.up2(d3), e1], dim=1)) d1 = self.dec1(torch.cat([self.up1(d2), e0], dim=1)) return self.head(d1)这段代码的关键点有三个。第一,enc0把 conv1、bn1、relu、maxpool 串起来,输出是 1/4 分辨率、64 通道,正好对应解码器最后一层拼接。第二,每个解码块先ConvTranspose2d上采样两倍,再和同分辨率编码特征cat,通道数相加后进_block。第三,head用 1x1 卷积把 32 通道映射到num_classes=2,输出和输入同分辨率。
参数上,num_classes二分类就是 2,别写成 1 再用 Sigmoid,那样损失函数和评估都要跟着改,容易乱。pretrained=True时第一次跑会下载权重,离线环境记得提前把权重文件放到~/.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_size=512, 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), interpolation=cv2.INTER_LINEAR) mask = cv2.resize(mask, (nw, nh), interpolation=cv2.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, value=0) 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, eps=1e-6): # logits: [B, 2, H, W], targets: [B, H, W] probs = F.softmax(logits, dim=1)[:, 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_weight=0.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_norm=5.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_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=50, eta_min=1e-6)clip_grad_norm_的max_norm=5.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(dim=1) # [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。解决:换成 BCE+Dice 组合,或者给前景像素加权重。如果已经用了组合损失还是这样,检查 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 的形态学梯度生成边界带,给边界像素更高权重,对粘连细胞核的分离效果明显。
| 技巧 | 预期涨幅 | 代价 | 适用条件 |
|---|---|---|---|
| 多尺度 TTA | +1~2 IoU | 推理 x3 | 通用,优先做 |
| 换 Resnet50 | +1~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 轮就很明显了,趋势不对就赶紧回头查数据和损失,别硬等。这套 Unet+Resnet 加多尺度训练的框架,我在几个不同的涂片数据集上都跑过,基础配置稳定在 0.82 到 0.85,加上 TTA 和边界加权能到 0.88 左右。真正决定上限的不是网络结构,是标注质量和预处理的一致性。希望帮到你。
本文还有配套的精品资源,点击获取