简介:面向深度学习图像分割初学者与研究者的Unet实战项目,配套DUT-OMRON二值图像分割数据集,可直接用于训练、验证与二次开发。压缩包内共2000个文件,以PNG图像与掩码为主(1979个),另含Python脚本、XML配置、JPG示例及说明文档,整体约223.63MB,目录结构清晰便于按模块查阅。项目内置完整训练流程,train脚本自动计算灰度均值与方差用于归一化,并随机缩放至设定尺寸的0.5~1.5倍实现多尺度训练;transforms.py中的预处理函数全部重新实现,便于自行修改。模型训练50个epochs,最终mIoU约0.72,学习率采用cos衰减,run_results内保存损失与IoU曲线、训练日志及最优权重;预测脚本可自动推理inference目录下所有图片,配合README可快速用于自定义数据训练。目前已有442人学习下载,适合希望系统上手Unet分割并理解多尺度训练细节的读者。
1. 用DUT-OMRON跑通Unet二值分割:先从显著性数据集说起
接手一个二值分割需求时,大多数人第一反应是找一个公开数据集先跑通流程。但真跑到验证集上才发现,自建数据里目标居中、背景干净、对比度也高,Unet轻轻松松刷到0.9的mIoU;换到低对比度、背景杂乱的场景,同样一套代码直接掉到0.6。这个差距不全是模型的问题,更可能是数据集选得太“友好”。DUT-OMRON是深度学习视觉领域里出了名的“硬骨头”,5128张自然图像都带像素级二值标注,目标尺度变化大、边缘复杂、不少图像的目标和背景在纹理上几乎分不开。本文就是顺着这条线,把Unet二值图像分割的完整流程——从数据集切分、网络搭建、损失函数到训练避坑——逐一落地,让你在DUT-OMRON上跑出一个能复现、能对比、能拿去扩展的实战底座。适合正在做毕设、需要复现Unet对比实验,或者想验证自己分割方案鲁棒性的从业者。
2. 吃透DUT-OMRON:数据划分、标签清洗与预处理细节
2.1 数据集文件结构:原图与mask的配对关系
DUT-OMRON虽然叫显著性检测数据集,但它的标注形式对二值分割来说非常“原生”:每张原始图像对应一张同名PNG的mask图,白色像素是显著目标区域,黑色像素是背景。拿到压缩包解压后,一般是images和annotations两个目录,文件名一一对应。训练前第一步不是写网络,而是把文件结构摸清楚,脚本化地把数据切分成train/val/test三份。
import os import random import shutil from pathlib import Path def split_dataset(images_dir, masks_dir, output_root, train_ratio=0.7, val_ratio=0.2, seed=42): random.seed(seed) img_paths = sorted(Path(images_dir).glob("*.jpg")) + sorted(Path(images_dir).glob("*.png")) pairs = [] for img_p in img_paths: mask_p = Path(masks_dir) / (img_p.stem + ".png") if mask_p.exists(): pairs.append((img_p, mask_p)) random.shuffle(pairs) n_train = int(len(pairs) * train_ratio) n_val = int(len(pairs) * val_ratio) splits = { "train": pairs[:n_train], "val": pairs[n_train:n_train + n_val], "test": pairs[n_train + n_val:] } for split_name, items in splits.items(): img_out = Path(output_root) / split_name / "images" mask_out = Path(output_root) / split_name / "masks" img_out.mkdir(parents=True, exist_ok=True) mask_out.mkdir(parents=True, exist_ok=True) for img_p, mask_p in items: shutil.copy(img_p, img_out / img_p.name) shutil.copy(mask_p, mask_out / mask_p.name) print(f"{split_name}: {len(items)} pairs") split_dataset("DUT-OMRON/images", "DUT-OMRON/cartoon_mask", "DUT-OMRON/split")这段脚本的关键点是先配对再打乱,而不是分别打乱两张目录然后按索引取——很多第一次跑这个数据集的人就是在这里翻车:两个目录里文件数目不一样或者排序规则不同,shuffle后train里的图像和mask对不上,训练loss低到离谱,验证mIoU却几乎为0。shutil.copy而不是shutil.move,保留原始数据集,后续重跑实验不用重新解压。train_ratio和val_ratio按7:2:1划分是显著性检测任务里比较常见的比例,如果数据总量更大,可以调整成8:1:1,但DUT-OMRON只有5128对图像,7:2:1相对稳妥。
2.2 mask中隐藏的灰边:不清理会稳定拉低mIoU
很多人拿到mask后直接用PIL读取、转成0/1数组就开训,直到可视化预测结果时才注意:原标注图里目标边缘有一圈淡淡的灰色过渡,像素值在100到200之间。这在实际标注中很常见,但二值分割的标签必须是严格的两类,灰色像素既不归前景也不归背景,模型会被迫去拟合这些中间值,导致预测概率图在边缘处发软,mIoU上不去。
import numpy as np from PIL import Image def clean_mask(mask_path, threshold=127, output_path=None): mask = np.array(Image.open(mask_path).convert("L")) binary = (mask > threshold).astype(np.uint8) * 255 if output_path: Image.fromarray(binary).save(output_path) return binary # 对整个数据集的mask执行一轮清洗 for mask_path in sorted(Path("DUT-OMRON/split").rglob("masks/*.png")): clean_mask(str(mask_path), threshold=127, output_path=str(mask_path))threshold取127的本质是把[0, 255]灰度区间一分为二。如果标注风格偏保守、目标主体比背景亮但亮度不高,可以取100;如果目标区域非常亮、背景接近纯黑,取127几乎没有风险。这里要强调一个习惯:清洗后的mask要用PNG保存,不能存成JPG,否则压缩会重新引入灰色边缘,等于白洗。对unet训练自己的数据集来说,这一步是数据质量的保底操作,DUT-OMRON的高频使用场景是显著性检测,但本文把它当二值分割用,所以标签的严格二值化优先级更高。
2.3 尺寸策略:resize用双线性还是最近邻
DUT-OMRON原始图像分辨率并不统一,有小到300×300的,也有接近400×500的。网络输入尺寸常见做法是统一resize到256×256或288×288。关键坑在于:图像可以用双线性插值,mask必须用最近邻插值,否则mask边缘会生成一堆非0非255的像素,相当于把2.2节的灰边问题重新引入。
from PIL import Image def resize_pair(image, mask, size=(256, 256)): img = image.resize(size, Image.BILINEAR) msk = mask.resize(size, Image.NEAREST) return img, msk如果你显存比较充裕,建议用288×288而不是256×256,DUT-OMRON里很多目标占图像比例很小,分辨率太低时小目标直接变成几个像素点,模型根本学不到边缘细节。另外,每次训练前on-the-fly resize比预先生成resize后的图片更灵活——改输入尺寸只需要改一行代码,不用重新跑一遍全量脚本。代价是训练时CPU预处理压力稍大,但DUT-OMRON只有5128张图,这个开销可忽略。
3. 从零搭一个Unet:编码器、跳跃连接与输出层设计
3.1 双卷积块与编码器结构:通道数怎么定最省显存
Unet的核心单元是“两次卷积+ReLU+BN”的双卷积块,下采样用stride=2的max pooling,通道数每经过一次下采样翻倍。从零搭建时最常见的纠结点是首层通道数取多少。取16则模型太薄,特征表达不足;取64则显存压力大,训练速度明显变慢。我一般取32作为baseline,DUT-OMRON这种中等规模数据集足够。
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, kernel_size=3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, kernel_size=3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True) ) def forward(self, x): return self.conv(x) class Encoder(nn.Module): def __init__(self, in_channels=3, base_channels=32): super().__init__() self.down1 = DoubleConv(in_channels, base_channels) self.down2 = DoubleConv(base_channels, base_channels * 2) self.down3 = DoubleConv(base_channels * 2, base_channels * 4) self.down4 = DoubleConv(base_channels * 4, base_channels * 8) self.pool = nn.MaxPool2d(kernel_size=2, stride=2) def forward(self, x): d1 = self.down1(x) p1 = self.pool(d1) d2 = self.down2(p1) p2 = self.pool(d2) d3 = self.down3(p2) p3 = self.pool(d3) d4 = self.down4(p3) return d1, d2, d3, d4每次下采样后特征图分辨率减半、通道数翻倍,目的是在保持信息容量的同时逐步扩大感受野。BN放在ReLU前面是PyTorch里的常规顺序,在batch较小(小于8)时BN的统计量会抖动,后面避坑章节会专门展开。默认encoder层数为4,对256×256输入来说,最深一层分辨率是16×16,再往下池化到8×8时信息损失较大,收益有限,所以这里停在d4。
3.2 解码器与跳跃连接:为什么Unet在这里比纯FCN稳定
解码器的核心操作是把深层特征上采样回原分辨率,并通过跳跃连接(skip connection)把同尺度的浅层特征拼回来。浅层特征保存的是边缘、纹理细节,深层特征保存的是语义类别信息,拼接后两者互补——这正是Unet在二值图像分割上比纯FCN稳定的原因:DUT-OMRON中有大量目标边界和背景纹理相似,只靠深层语义特征上采样恢复边界会丢失细节,跳跃连接等于让浅层细节信息走了一条信息高速公路。
class Decoder(nn.Module): def __init__(self, base_channels=32): super().__init__() self.up4 = nn.ConvTranspose2d(base_channels * 8, base_channels * 4, kernel_size=2, stride=2) self.double4 = DoubleConv(base_channels * 8, base_channels * 4) self.up3 = nn.ConvTranspose2d(base_channels * 4, base_channels * 2, kernel_size=2, stride=2) self.double3 = DoubleConv(base_channels * 4, base_channels * 2) self.up2 = nn.ConvTranspose2d(base_channels * 2, base_channels, kernel_size=2, stride=2) self.double2 = DoubleConv(base_channels * 2, base_channels) def forward(self, x, skips): x = self.up4(x) x = torch.cat([x, skips[3]], dim=1) x = self.double4(x) x = self.up3(x) x = torch.cat([x, skips[2]], dim=1) x = self.double3(x) x = self.up2(x) x = torch.cat([x, skips[1]], dim=1) x = self.double2(x) return x class Unet(nn.Module): def __init__(self, in_channels=3, base_channels=32, num_classes=1): super().__init__() self.encoder = Encoder(in_channels, base_channels) self.decoder = Decoder(base_channels) self.head = nn.Conv2d(base_channels, num_classes, kernel_size=1) def forward(self, x): d1, d2, d3, d4 = self.encoder(x) x = self.decoder(d4, [d1, d2, d3, d4]) return self.head(x)up层用的是ConvTranspose2d,跳跃连接前先上采样再拼接。head层用1×1卷积把通道数压到1,输出的是logits而不是经过sigmoid的概率,方便和BCEWithLogitsLoss搭配,数值更稳定。base_channels=32时模型参数量大约在7M上下,RTX 3060级别显卡batch_size可以开到16,训练速度非常可观。
3.3 改进方向参考:哪些地方值得为DUT-OMRON定制
跑通baseline之后再谈unet模型改进。DUT-OMRON上最常见的两个痛点是:小目标占比低导致漏检、边缘处预测和标注贴合度差。常见做法是把skip connection从单纯拼接换成带通道注意力的拼接,也就是在拼接后加一个SE模块或简单的全局平均池化+两层全连接做通道加权。第二个做法是把最后一层head换成带深监督的结构,在decoder的每一层都接一个auxiliary loss,让小目标梯度能直接传到浅层。这两个改动都不会让模型结构变得太重,但能肉眼可见地把mIoU提升1到3个百分点。不过做改进之前,务必先把baseline复现到稳定水平,否则改进点和训练不稳定的噪声混在一起,很难判断哪个改动真正起作用。
4. 训练配置与损失函数:Dice、BCE还是组合损失全都要
4.1 损失函数对比:正负样本不均衡是二值分割的头号敌人
DUT-OMRON的图像里显著目标区域平均只占整幅图的20%到40%,很多图甚至低于10%,直接用BCE时负样本占主导,模型会倾向把大半个图预测成背景,虽然BCE损失很低,但mIoU惨不忍睹。这是二值图像分割里最经典的问题:正负样本比例极度不均衡。BCEWithLogitsLoss单独用几乎必然翻车,DiceLoss对正负样本比例不敏感,因为它的分母同时包含预测和真实的正样本,天然关注目标区域的重合度,所以组合损失是主流做法。
class DiceLoss(nn.Module): def __init__(self, smooth=1.0): super().__init__() self.smooth = smooth def forward(self, logits, targets): probs = torch.sigmoid(logits) probs = probs.reshape(probs.size(0), -1) targets = targets.reshape(targets.size(0), -1) intersection = (probs * targets).sum(dim=1) denominator = probs.sum(dim=1) + targets.sum(dim=1) dice = (2.0 * intersection + self.smooth) / (denominator + self.smooth) return 1.0 - dice.mean()smooth参数有两个作用:一是防止目标区域在mask里完全缺失时分母为0导致除零;二是作为软化因子,smooth越大, dice损失对单个像素的波动越不敏感。在DUT-OMRON这种已经清洗过标签的数据集上,smooth取1.0是标准做法,取2.0以上会让边界处的梯度变钝。
4.2 组合损失与训练循环:超参怎么配、early stopping怎么写
常见做法是BCE和Dice按0.5:0.5组合,也可以按0.6:0.4,BCE太轻则训练前期收敛慢,Dice太轻又拉不回来正负样本不均衡。我在这个数据集上习惯用BCE+Dice组合,初始学习率3e-4,优化器选AdamW而不是SGD——省去手动调momentum和weight decay的麻烦,配合warmup和cosine退火能稳定收敛。
def training_loop(model, train_loader, val_loader, epochs=60, lr=3e-4): optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs) bce_loss = nn.BCEWithLogitsLoss() dice_loss = DiceLoss(smooth=1.0) best_iou = 0.0 for epoch in range(epochs): model.train() running_loss = 0.0 for images, masks in train_loader: optimizer.zero_grad() logits = model(images) loss = 0.5 * bce_loss(logits, masks) + 0.5 * dice_loss(logits, masks) loss.backward() optimizer.step() running_loss += loss.item() val_iou = evaluate_iou(model, val_loader) scheduler.step() if val_iou > best_iou: best_iou = val_iou torch.save(model.state_dict(), "best_model.pth") print(f"Epoch {epoch+1}: loss={running_loss/len(train_loader):.4f}, val_iou={val_iou:.4f}") def evaluate_iou(model, val_loader): model.eval() iou_list = [] with torch.no_grad(): for images, masks in val_loader: logits = model(images) probs = torch.sigmoid(logits) preds = (probs > 0.5).float() intersection = (preds * masks).sum(dim=(1, 2, 3)) union = preds.sum(dim=(1, 2, 3)) + masks.sum(dim=(1, 2, 3)) - intersection iou = (intersection + 1e-6) / (union + 1e-6) iou_list.append(iou) return torch.cat(iou_list).mean().item()代码里有两个关键细节。一是loss加权系数0.5/0.5,如果发现验证集上mIoU在上不去,优先调整这个比例而不是换网络结构;二是只保存验证集mIoU最高的权重——如果每个epoch都覆盖保存,训练结束后拿到的很可能是过拟合权重,mIoU比中间epoch差一大截。scheduler在每次epoch结束后step,配合early stopping直接在训练循环里用if判断实现,属于”加量不加价”的稳定手段。评估函数的preds直接固定阈值0.5,这里有个潜在问题会在避坑章节展开。
4.3 数据增强:哪些手段有效、哪些手段会让mask损坏
DUT-OMRON只有5128张图,不过模型容量不大,单靠数据增强就能压住过拟合风险。有效的增强包括随机水平翻转、随机旋转15度以内、随机裁剪加resize、小幅色彩抖动。特别提醒:随机裁剪之后一定要把图像和mask用完全相同的变换参数处理,否则几何不对齐,等于给标签主动引入噪声。
import random import torchvision.transforms.functional as F def pair_transform(image, mask): # 随机水平翻转 if random.random() > 0.5: image = F.hflip(image) mask = F.hflip(mask) # 随机旋转(小角度,mask用最近邻) angle = random.uniform(-15, 15) image = F.rotate(image, angle, interpolation=F.InterpolationMode.BILINEAR) mask = F.rotate(mask, angle, interpolation=F.InterpolationMode.NEAREST) # 随机裁剪后resize回256 i, j, h, w = random.randint(0, 16), random.randint(0, 16), 224, 224 image = F.resized_crop(image, i, j, h, w, (256, 256)) mask = F.resized_crop(mask, i, j, h, w, (256, 256), F.InterpolationMode.NEAREST) return image, mask旋转和resize后mask必须用NEAREST插值,这是反复强调的点。色彩抖动只对image做、不对mask做,因为mask是几何标注,颜色信息与它无关。另一个常见误区是在验证阶段也做增强——验证必须关闭所有随机变换,否则每次评估结果都在变,根本没法判断模型真实水平。
5. 避坑手册:DUT-OMRON上跑Unet的常见问题与排查
5.1 验证mIoU有0.71,可视化却一塌糊涂
现象:验证集mIoU数值正常,但把预测mask叠加到原图上,发现目标中心预测得很饱满,边缘却像被狗啃过一样参差不齐,有些细长的突起完全缺失。
原因:DUT-OMRON的标注风格比较精细,很多目标有细长的触角、枝杈结构,在256×256分辨率下这些结构只有2到3个像素宽。可学习的下采样加最大池化会直接把这种细结构抹掉,且双卷积块后层的感受野太大,边缘处前景和背景像素比例接近1:1,模型倾向把它们一并归入背景以降低损失。
解决:输入尺寸从256提到320或288,边缘细节保留效果立竿见影。如果显存不够,用随机裁剪而不是直接resize,训练时裁剪128×128的小块,推理时拼回原尺寸。另外可以在损失函数里对边缘像素加权,对mask做一次Canny边缘检测生成权重图,边缘处loss权重放大两倍。这种处理相当于告诉模型:别把边缘当噪声,这里是高分区域。
5.2 训练loss下降但val_iou长期卡在0.4
现象:train loss从0.7降到0.2,val_iou却从第一个epoch开始就稳定在0.4左右,换学习率、换优化器都无效。
原因:数据划分泄漏。原数据集的原始组织方式可能按拍摄场景分组,同场景的多张图内容高度相似。shuffle时没有按场景分组划分,train里出现的图和val里的图长得几乎一样,模型“记住”了train的分布,val_iou看似正常但实际测试集上表现非常差。还有一种可能:随机划分时某个难样本恰好全部分到val里,导致验证集被“毒化”。
解决:先查看原始文件夹里是否有按场景或类别组织的子目录,有则按目录级别划分,保证同一个场景的图不进两个集合。没有子目录的话,用文件名前缀聚类或直接计算图像感知哈希,把相似度高的图放在同一个split里。DUT-OMRON没有官方划分,这个步骤必须自己做,忽略它的话实验结论的可信度很低。
5.3 Unet代码在单卡上正常,多卡训练时loss曲线剧烈抖动
现象:单卡训练loss平滑下降,换到DataParallel多卡后loss每个step都在剧烈震荡,验证集iou反而变差。
原因:PyTorch的DataParallel把batch切到多张卡上,每张卡上的batch_size变小,BatchNorm的均值和方差统计不稳定。DUT-OMRON图像本身分辨率不高,batch_size 16时分配到4张卡上每卡只有4张图,BN几乎是在用几张小图的统计量做归一化,效果当然差。
解决:最简单的方式是换用SyncBatchNorm,把所有卡上的统计量同步后再归一化;或者干脆保持单卡训练,用梯度累积模拟大batch——每步取batch_size=4的输入反向传播,累积4次梯度后再更新参数,等效batch_size=16。代码上只需要在每个step判断累计次数是否足够再执行optimizer.step()。这个坑在unet使用时的注意事项里经常被提到,遇到多卡表现异常先查BN设置。
5.4 预测结果整体偏“白”:背景区域也被预测成前景
现象:概率图里大块背景区域概率值在0.4到0.6之间浮动,固定阈值0.5切出来之后背景噪点特别多。
原因:DUT-OMRON中部分图像的显著目标很小,背景占比可能高达90%以上,模型学到的是“这幅图大概率有目标”,无法精确区分目标和背景。另外训练时如果没做背景采样平衡,背景像素的梯度贡献被DiceLoss弱化,背景区域概率值就会偏高但不稳定。
解决:推理时不要用固定阈值0.5,改用Otsu大津法求全局最优阈值,往往能自动落在0.55到0.7之间,显著过滤背景噪点。如果Otsu效果仍一般,加上连通域后处理:预测mask里面积小于全图1%的连通域全部当作噪点删除,这个方法对小目标数据集的精度提升比换模型更直接。Otsu和连通域的具体实现放到下一章,因为它们属于推理阶段的后处理,和训练分开调试更清晰。
5.5 预训练encoder加载报错:通道数对不上
现象:想用ResNet34做Unet的encoder做unet模型改进,加载torchvision预训练权重时提示state_dict的key不匹配或尺寸不匹配。
原因:ResNet34首层卷积是Conv2d(3, 64, kernel_size=7, stride=2),而你的Unet首层卷积是自定义DoubleConv,输出通道数是32或64但不带下采样,key的名字也对不上。
解决:常见的兼容做法是自己写一个适配层:把ResNet的conv1、bn1、relu、maxpool原样保留,去掉最后的avgpool和fc,把Unet的encoder替换成ResNet前四层,并在forward里分别输出每一层的特征图作为skip connection。如果输入只有单通道灰度图,常见做法是把灰度图复制成3通道再喂进去,而不是修改Conv2d输入通道数——修改权重文件反而会降低预训练收益。
6. 从概率图到预测mask:Otsu阈值与连通域后处理实战
训练结束后拿到best_model.pth,离”交付”还有一个完整阶段——推理后处理。这里有一个很多实战项目都会忽略的问题:模型输出的概率图不等于最终分割结果,阈值选多少、怎么去噪,直接影响最终交付指标。
import cv2 import numpy as np import torch def post_process(prob_map): # prob_map: HxW,取值0~1的numpy数组 prob_uint8 = (prob_map * 255).astype(np.uint8) # Otsu自动求阈值,避免固定0.5的偏差 otsu_thresh, mask = cv2.threshold(prob_uint8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 连通域过滤:删除面积小于全图1%的白色区域 num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8) min_area = prob_map.shape[0] * prob_map.shape[1] * 0.01 filtered = np.zeros_like(mask) for label_id in range(1, num_labels): if stats[label_id, cv2.CC_STAT_AREA] >= min_area: filtered[labels == label_id] = 255 return filteredOtsu在这里的作用比很多人想象中大:当模型在背景区域输出0.4左右的概率时,固定阈值0.5会把整块背景都保留下来,而Otsu会根据概率直方图的分布自动找到一个把前景和背景分开的谷底。连通域过滤则解决另一个问题——DUT-OMRON里有些图的目标很小,但预测结果里零星散布着几十个像素级别的白色噪点,它们面积小、数量多,虽然对mIoU影响不大,但在实际业务里会被人当成严重的误检。面积阈值取1%是基于DUT-OMRON里最小目标约占全图2%的经验,如果对召回率要求高,可以降到0.5%甚至不设下限。
后处理写完后,最终验证环节还有一步:直接把预测mask与原图做alpha叠加可视化,不要只看mIoU数字。我的习惯是随机抽20张验证集图像,把原图、mask真值、预测mask三张并排存成一张拼图,快速扫一眼。这一步翻过无数次车:指标好看的模型有时会把目标整个预测偏半个身位、把背景纹理预测成目标轮廓。指标只能告诉你“数字不错”,可视化才能告诉你“东西对不对”。DUT-OMRON这类数据集指标上限很高,但业务落地的门槛从来都是边缘贴合度和漏检率,希望这些步骤能帮你把模型从“跑通”推进到“能交付”。
本文还有配套的精品资源,点击获取