简介:UNet语义分割实战资源包面向深度学习入门者与CV开发者,聚焦图像二分类分割任务。资源以单通道输出的分割思路为主线,对比两种常见做法,并给出经sigmoid、交叉熵训练逼近标签图的完整方案,适合需要从零搭建UNet训练流程的读者。压缩包共包含3430个文件,其中3404张png格式的图片构成训练与测试数据集,另有11个Python脚本、Dockerfile、依赖配置及说明文档等,整体约987MB。数据与代码分离的目录结构便于直接运行和替换数据集,适合结合配套博文逐步复现实验。目前已有2816人学习下载。资源内提供了完整可跑通的工程代码,并附有图片数据与README说明,可帮助读者快速掌握UNet二分类的模型搭建、损失计算和训练细节,减少环境配置与数据准备的时间成本。
1. 为什么拿UNet做人物抠图:这道题本质是像素二分类
做活动海报、商品图、人物证件照背景替换,最烦的就是抠头发丝。用PS魔棒和快速选择工具,边缘毛糙不说,遇到浅色头发和浅色背景混在一起,选区一塌糊涂。换成一个UNet语义分割模型来做,问题就变成了另一件事:把每个像素判断成“人物”或“背景”,输出一张和原图同尺寸的黑白掩膜,黑的就是背景,白的就是人物。这张掩膜拿来当alpha通道用,背景一换就完成抠图。
这个思路适合手里有一批标注过的人像图、想脱离在线抠图接口、又不想上太重设备的人。不需要GPU集群,一张普通显卡就能训练。UNet结构简单,是理解语义分割算法的最佳切入点,也是把“抠图”从手工操作转成批量产线的最短路径。下面按数据准备、模型搭建、训练调参、踩坑、后处理这条线完整过一遍。
2. 从人像图到训练标签:数据准备与目录结构
2.1 语义分割任务的第一步:把“人物”和“背景”变成两张图
UNet训练需要成对的数据:一张是原图,一张是标注好的掩膜(mask)。掩膜是单通道灰度图,人物区域像素值为255,背景为0。任务从“抠图”这个感觉上很复杂的操作,被简化成了逐像素分类——前背景二分类,这是UNet图像分割里最基础但也最稳的任务形式。相比实例分割要区分“第几个人”,语义分割只需要回答“这个像素是不是人”,计算量小一个量级,模型也更容易收敛。
数据来源常见做法是两类:一是直接用公开的人像分割数据集,像PPMI、AISegment这类,里面是整理好的人像原图和对应掩膜;二是自己采集图片,用labelme或类似标注工具沿着人物轮廓打多边形点,导出JSON再转成掩膜。自己标注的好处是数据和你的应用场景完全一致,比如你只做半身人像,分类器就只认识半身人像;缺点是费时间,一张精细图标下来少说三五分钟。
自己标注时注意一个原则:轮廓点宁可多打也不要少打。头发丝和衣领边缘是标注难点,这两处点太稀,模型学到的边界就是折线,后处理怎么修都救不回来。
2.2 标注工具选择与质量要求:宁可少标不可乱标
labelme是目前最顺手的多边形标注工具,安装简单,标注结果是一个JSON文件,里面记录每个多边形的顶点坐标。对人物抠图来说,一张图只需要一个多边形,就是人的外轮廓。
标注质量直接影响模型上限。三件事必须检查:第一,轮廓贴合度,尤其是发梢和手指缝隙,点要密集;第二,遮挡关系,如果人物手持物品,这个物品算不算人物的一部分,需要在标注前定好标准,不然不同图片标注口径不一致,模型会学混乱;第三,裁剪范围,数据集里不要混入只有半张脸的大特写和全身远景,分辨率差异太大会让模型对“人物尺度”产生错误先验。
2.3 数据目录约定与Dataset类的写法
目录结构可以这样定,简单且不容易出错:
dataset/ images/ 001.jpg 002.jpg masks/ 001.png 002.png图片和掩膜保持同名,只是后缀不同。掩膜统一存PNG,因为PNG是无损压缩,JPG的压缩会在大面积纯色边缘产生块状噪点。原图和掩膜分辨率保持一致,不匹配的情况在读取时直接报错退出,不要静默resize,否则排查问题时会疯掉。
下面是PyTorch的Dataset类写法,这是整个训练流程的地基:
import torch from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as T import torchvision.transforms.functional as TF import random class SegmentationDataset(Dataset): def __init__(self, img_dir, mask_dir, img_size=320, augment=False): self.img_dir = img_dir self.mask_dir = mask_dir self.img_size = img_size self.augment = augment self.names = [p.stem for p in Path(img_dir).glob("*.jpg")] def __len__(self): return len(self.names) def __getitem__(self, idx): name = self.names[idx] image = Image.open(f"{self.img_dir}/{name}.jpg").convert("RGB") mask = Image.open(f"{self.mask_dir}/{name}.png").convert("L") # 统一尺寸,掩膜必须用最近邻插值 image = image.resize((self.img_size, self.img_size), Image.BILINEAR) mask = mask.resize((self.img_size, self.img_size), Image.NEAREST) if self.augment: image, mask = self._augment(image, mask) # 掩膜二值化:只要大于128就视为前景,避免标注残留 mask = torch.from_numpy(np.array(mask)).float() / 255.0 mask = (mask > 0.5).float() image = T.ToTensor()(image) # ImageNet的均值和标准差,迁移学习常用 image = T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])(image) return image, mask.unsqueeze(0)这里有两个参数值得单独讲。img_size=320是输入分辨率,人物抠图不需要太高,320×320在边缘细节和显存占用之间比较平衡,实践中很多场景256也能跑,只是边缘会明显糙一点。augment开关控制增强,训练集打开、验证集关闭,否则验证指标会被数据增强干扰。
掩膜resize用Image.NEAREST而不是BILINEAR是有原因的。双线性插值会在掩膜边缘产生介于0和255之间的过渡灰度值,比如183这种数字,后面二值化时这些值会被随机归到前景或背景,造成标注边缘抖动。最近邻插值不会产生中间值,掩膜的边缘始终是硬边,模型学到的边界更稳定。
3. UNet模型结构与训练配置:哪些模块可以动、哪些参数必须先定
3.1 选UNet而不是DeepLabV3的理由:小数据、单目标、细节边缘
语义分割算法里UNet、DeepLabV3、SAM都是常见选项,但人物抠图这个具体场景,UNet的性价比最高。DeepLabV3靠空洞卷积扩大感受野,在复杂背景、多类别分割上表现更好,但对单目标人像分割来说属于杀鸡用牛刀,模型更重、训练更慢,边缘细节反而因为空洞卷积的grid效应偶尔出现棋盘格伪影。
SAM这类大模型通用性很强,但显存占用和推理延迟都高一个数量级,在批量处理场景下单位成本划不来。UNet的编码器-解码器对称结构加跳跃连接,天然适合保留空间细节——这正是人物边缘最需要的。如果你要区分多个人并单独抠出每个人,那才是实例分割的范畴;只做“人”和“背景”两类,UNet是公认最稳的起点。
3.2 模型结构里的四个关键点:编码器、跳跃连接、深度、输入分辨率
UNet不是非得原封不动照搬论文。原文用的双卷积加池化是很久以前的设计,现在做人物抠图一般用预训练的ResNet34或MobileNetV2做编码器,收敛速度快很多,效果也更好。解码器保持UNet经典的上采样加跳跃连接即可。
跳跃连接是UNet的核心,它把编码器下采样前的特征图拼接到解码器对应层上。浅层特征图分辨率高、包含边缘纹理信息;深层特征图语义强、知道哪块是人物哪块是背景。两者拼接,模型才既“看得懂”又“看得清”。实现上要确认编码器和解码器特征图的通道数和尺寸能对上,拼接报错八成是这里的维度不匹配。
下采样次数建议控制在3到4次。输入320×320,下采样3次得到40×40的最低分辨率特征图,对人物这个单目标足够。下采样次数过多,小区域如手指缝隙、发丝在最低分辨率层直接消失,解码器怎么上采样都补不回来。训练时watch最低层特征图,如果上面已经看不出人物轮廓,就是下采样过度了。
3.3 输出层和损失函数:为什么不能用纯交叉熵
UNet输出层用1通道加sigmoid,而不是2通道加softmax。二分类问题1通道就够,前背景概率是互补的,输出2通道纯属浪费显存和计算量。
损失函数是训练能否收敛的关键。人物抠图有个天然问题:背景像素远多于前景像素,一张常见的人像图里背景占六到七成。如果只用BCELoss,模型发现全预测背景也有不错的表现,训练过程就会陷入“全背景”局部最优。常见做法是BCE加DiceLoss的组合:
import torch.nn.functional as F def bce_dice_loss(pred, target, bce_weight=0.5): # pred: 模型输出经过sigmoid后的概率图,target: 0/1掩膜 bce = F.binary_cross_entropy(pred, target) smooth = 1.0 intersection = (pred * target).sum() dice = 1.0 - (2.0 * intersection + smooth) / (pred.sum() + target.sum() + smooth) return bce_weight * bce + (1 - bce_weight) * dicebce_weight=0.5表示两种损失各占一半,这是比较通用的起点。DiceLoss直接优化分割区域的重合度,不怕正负样本不平衡,但它本身是非凸的,配合BCE一起用训练更稳定。如果发现训练初期掩膜全是背景、前景完全没出来,把bce_weight提到0.7,让交叉熵主导前期训练,把模型从“全背景”状态拉出来。
评估指标用IoU(交并比),不用准确率。准确率在类别不平衡时严重失真,模型全预测背景也能拿到80%以上的准确率,而IoU会诚实地告诉你前景区域到底预测对了多少。人物抠图经验里,验证集IoU超过0.9,抠图结果就基本能商业使用了。
4. 训练跑通与调参:最小可复现的完整流程
4.1 训练主脚本:从DataLoader到checkpoint
下面这个训练脚本是完整可跑的,把第2章的Dataset类直接拿过来用就行。没有写分布式训练,单卡场景不需要那些复杂逻辑:
import torch import torch.optim as optim from torch.utils.data import DataLoader from tqdm import tqdm # 假设model已经定义好,比如from model import UNet device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = UNet(backbone="resnet34", num_classes=1).to(device) train_dataset = SegmentationDataset("dataset/images", "dataset/masks", img_size=320, augment=True) val_dataset = SegmentationDataset("dataset/val_images", "dataset/val_masks", img_size=320, augment=False) train_loader = DataLoader(train_dataset, batch_size=8, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=8, shuffle=False, num_workers=4, pin_memory=True) optimizer = optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5) # 余弦退火,比固定学习率效果稳定 scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50, eta_min=1e-6) best_iou = 0.0 for epoch in range(50): model.train() train_loss = 0.0 for images, masks in tqdm(train_loader): images, masks = images.to(device), masks.to(device) preds = torch.sigmoid(model(images)) loss = bce_dice_loss(preds, masks) optimizer.zero_grad() loss.backward() # 梯度裁剪,防止偶然的loss尖峰把参数打飞 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=2.0) optimizer.step() train_loss += loss.item() scheduler.step() # 验证集上算IoU model.eval() total_iou = 0.0 with torch.no_grad(): for images, masks in val_loader: images, masks = images.to(device), masks.to(device) preds = torch.sigmoid(model(images)) preds_bin = (preds > 0.5).float() intersection = (preds_bin * masks).sum(dim=(1, 2, 3)) union = preds_bin.sum(dim=(1, 2, 3)) + masks.sum(dim=(1, 2, 3)) - intersection total_iou += (intersection / (union + 1e-6)).mean().item() val_iou = total_iou / len(val_loader) if val_iou > best_iou: best_iou = val_iou torch.save(model.state_dict(), f"best_model_iou_{val_iou:.4f}.pth") print(f"Epoch {epoch} | Loss: {train_loss/len(train_loader):.4f} | Val IoU: {val_iou:.4f}")关于这个脚本有几个参数值得展开。weight_decay=1e-5是L2正则,轻微限制权重范数,人物分割这类数据量不大的任务加上它几乎总是有益。max_norm=2.0的梯度裁剪不是标配,但建议留着,训练中出现loss突然变成nan然后整个模型废掉的情况时,这个参数就是后悔药。CosineAnnealingLR的T_max=50要和总epoch数一致,学习率会从初始值平滑下降到eta_min,比学习率一成不变时最终IoU高出1到2个百分点,在接近训练尾声时尤为明显。
训练时用num_workers=4可以把数据加载和GPU计算重叠起来。显存不够时先降到8的batch size对应的数字,而不是把num_workers调到0,那样训练速度会肉眼可见地慢下来。
4.2 影响收敛的四个训练参数
第一个是学习率。lr=1e-4配AdamW是人物分割场景的稳妥起点,不要一上来就用3e-4或1e-3。UNet的编码器如果是预训练权重,编码器的学习率还可以单独设低一点,比如1e-5,防止预训练特征被快速破坏。最简单的做法是把模型参数按编码器、解码器分组,分别设置学习率。
第二个是batch size。目标检测和分类任务里batch size可以从32起,但分割任务每张图的显存占用大得多,8是一个比较均衡的值。batch size太小(比如2或4)时,BatchNorm的均值和方差估计不稳定,训练loss会来回震荡,这种情况要么增大batch size,要么用GroupNorm替换BatchNorm。
第三个是输入分辨率。训练时320×320,推理时也必须是320×320,模型对训练时的分辨率范围有依赖。如果推理图片是1000×2000的长图,直接喂进去效果会明显变差,正确做法是切成patch或者先等比缩放再padding到320×320,padding区域的值用图像均值填充。
第四个是epoch数量。50个epoch是常见配置,但要看验证集IoU曲线,如果30个epoch就已经收敛,后面20个epoch就是过拟合的开始。反过来如果50个epoch还在缓慢上升,说明数据量偏大或学习率过低,应当延长训练而不是草率收工。
4.3 训练中看什么:loss曲线和可视化预测
训练时不要只盯着终端上的loss数字。每5个epoch保存一批验证集的可视化结果,把原图、掩膜、预测概率图并排放在一起,这是判断模型是否健康的最快方式。loss下降只能说明损失函数在优化,但模型可能学到了错误的东西,比如把地板阴影当成人物,或者把所有深色区域都预测成背景,这些只有亲眼看到预测图才能发现。
验证集IoU达到0.95以上时,loss曲线看起来几乎平了,此时继续训练收益很小。过拟合的直接信号是训练loss持续下降、验证IoU开始回落,这时应当回到之前IoU最高的checkpoint,而不是用最后一个epoch的权重。这也是脚本里只在验证IoU创新高时才保存模型的原因——你没那么多时间去逐个试每个checkpoint。
5. 人物抠图训练避坑:五个让我翻过车的真实场景
5.1 抠出的图边缘有一圈灰白色描边
现象:模型抠出的图整体不错,但只要换到深色背景上,人物轮廓周围就有一圈发白的边,像是描了层半透明的边。
原因:掩膜在resize时用了双线性插值产生中间灰度值,模型学到了“边缘一带的输出概率是0.3到0.7之间的模糊值”,后处理再用0.5阈值去切,就把这部分模糊区域切成了半透明的灰边。另外有些标注工具导出的掩膜边缘自带羽化,也是同样的问题。
解决:第2章Dataset里resize强制用NEAREST是在数据侧堵住这个坑。训练侧还有一个补救办法:把输出sigmoid概率图做一个3×3的min pooling再和原概率图比较,概率值变小则置为0,这叫腐蚀操作,能把边缘那圈半透明区域削掉。做一次3×3膨胀再腐蚀的组合也行,先闭运算填掉掩膜上的小孔,再开运算去掉边缘毛刺。
5.2 训练loss在下降,但预测图全黑
现象:训练过程没有报错,loss从0.7缓慢降到0.3,看起来很顺利,可视化时发现所有验证图的预测掩膜都是一片黑,即全部预测为背景。
原因:类别不平衡隐藏在loss下降的假象里。背景像素占多数,BCE那部分的loss大概率已经收敛了,DiceLoss虽然能拉回前景,但前景区域太小,梯度信号弱,模型停留在“全背景”的局部最优里出不来。
解决:前文bce_dice_loss里的bce_weight从0.5调高到0.7或0.8,交叉熵的梯度信号先主导训练,让模型先学会在图像上“找”人。还有一个独立技巧:训练第一个epoch时用更大的batch并打乱顺序,不洗牌的情况下训练集前几张图如果恰好全是背景占比极高的图片,起步就更偏。
5.3 头发丝区域成片丢失,边缘糊成一团
现象:验证IoU不低,但放大人脸区域,头发丝和背景之间没有过渡,细碎发梢部分直接被模型判成了背景,整个发型轮廓像是剪贴画。
原因:下采样次数过多,或者输入分辨率太低,细碎纹理在特征图里的响应被一起池化掉了。UNet的跳跃连接能保留浅层边缘信息,但前提是浅层特征的分辨率足够大,如果输入只有192×192,经过3次下采样后最低分辨率才24×24,头发丝已经按像素碎了。
解决:把输入分辨率提升到320×320以上,并保持3次下采样不变。另一个针对性做法是对头发区域做困难样本挖掘,把验证集里头发区域IoU低于平均值的图片挑出来单独配一个采样权重,让模型在每个epoch里更频繁地看到这类图。
5.4 训练时显存OOM,batch size降到2还是不行
现象:程序跑起来没几步就报CUDA out of memory,把batch size降到2甚至1仍然偶尔崩溃,卡死在半路。
原因:320×320输入、ResNet34编码器、batch size 8的组合显存占用约6GB到8GB,如果显卡只有8GB且同时有其它程序占用,就会OOM。降到batch size 2后BatchNorm失稳,训练又变得震荡,两头不讨好。
解决:优先换轻量backbone,MobileNetV2做编码器显存占用比ResNet34少将近一半,精度损失约1到2个点IoU。其次可以用梯度累积模拟大batch:
accumulation_steps = 4 # 每4个batch累加一次梯度,等效batch size = 8 x 4 for step, (images, masks) in enumerate(train_loader): loss = bce_dice_loss(torch.sigmoid(model(images.to(device))), masks.to(device)) loss = loss / accumulation_steps # 归一化,防止梯度累积导致loss爆炸 loss.backward() if (step + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()注意loss / accumulation_steps这一步不可省。梯度累积的本质就是把多次反向传播的梯度累加后再更新一次,如果不除累积步数,等效batch size变大的同时学习率也变大了,直接破坏训练稳定性。显存实在不够时,再配合pin_memory=False和num_workers=0,虽然会拖慢加载速度,但至少能跑。
5.5 验证集IoU高,真实拍照图片上却完全翻车
现象:公开数据集上验证IoU达到0.95,换到手机实拍照片上,人物和背景严重混淆,尤其是浅色衣服遇到浅色墙壁时,整个人被抹掉一块。
原因:数据分布偏移。公开人像分割数据集的背景大多是室内、街景这类常见场景,你实际要处理的是摄影棚灯光、白墙、反光地面,模型没见过。这是迁移学习里最常见的翻车现场,和模型本身能力无关。
解决:采集与真实应用场景一致的训练数据,做一次领域自适应微调。50张就够了,把你场景里最典型的背景组合挑出来标注,然后用现有权重作为初始化继续训练10个epoch,学习率调到1e-5。这个操作能把真实场景的IoU拉回来5到10个点,比换更复杂的模型有效得多。
6. 输出透明底PNG的一个实用技巧:在概率图上做形态学精修
训练完模型只是第一步,最终要交付的是能直接放进PS或设计稿里的透明底PNG。从模型输出到成品图,有一段很少被讲清楚的后处理流程。
先拿到sigmoid输出的概率图p,范围在0到1之间。直接把p当作alpha通道,用前景原图乘以p,得到的边缘是渐变的半透明,初看自然,实则经不起细看——边缘部分会带上一层背景色晕染。更稳妥的做法是先对p做一次0.5的二值化得到硬掩膜,再做3×3形态学闭运算填掉黑色小洞,最后用高斯模糊把硬掩膜的边缘柔和羽化。
import cv2 import numpy as np # pred: 模型输出的单通道概率图,CxHxW的numpy数组,范围0~1 prob = pred.squeeze(0).cpu().numpy().transpose(1, 2, 0) mask = (prob > 0.5).astype(np.uint8) * 255 # 闭运算:先膨胀后腐蚀,填充掩膜内部的孔洞 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=1) # 高斯模糊做羽化,sigma值控制在1~3,太大边缘发虚 alpha = cv2.GaussianBlur(mask, (5, 5), sigmaX=2).astype(np.float32) / 255.0 # 合成透明底PNG img = cv2.imread("input.jpg") # 原图BGR格式 img = cv2.resize(img, (alpha.shape[1], alpha.shape[0])) result = np.dstack([img, (alpha * 255).astype(np.uint8)]) # 注意保存格式必须是PNG,JPG不支持alpha通道 cv2.imwrite("output.png", result)sigmaX=2的取值是经验值。头发丝这类细节多的区域,sigma太大会把发梢糊掉;背景干净的半身像,sigma太小边缘会显得生硬。我一般会同时跑三个sigma值(1、2、3)抽样对比再定,这个步骤没必要做成全自动,人工选一次就好。
再往前走一步就是对头发丝的精细处理。UNet输出的硬掩膜天然是二值的,改不了发丝那种一根根半透明的光学效果,这是语义分割的上限,不是模型的缺陷。要突破这个上限,常见的方案是在UNet输出上再套一层trimap matting流程:把掩膜标注为“确定前景”“确定背景”“未知区域”三部分,未知区域就是边缘和发丝附近,然后用closed-form matting或KNN matting在未知区域上求解alpha。这个方案能让发丝边缘的透明度逼近PS的“选择并遮住”效果,但推理时间会翻倍,批量应用前先想清楚值不值得。
这套流程跑顺之后,再遇到人像抠图的需求,我的习惯是应该先看样本,再定阈值,最后才动手调网络。直接改网络结构往往是最后一步而不是第一步,光看IoU数字就上线,迟早会被奇奇怪怪的输入图教做人。希望帮到你。
本文还有配套的精品资源,点击获取