4. 语义分割入门
语义分割,说白了就是给图像里的每个像素打标签。你拍一张人像,模型得知道哪些像素属于人,哪些属于背景。这跟目标检测不一样——检测只画个框,分割要精确到发丝边缘。
我刚开始做人像分割时,总觉得这事儿挺简单。后来发现,头发丝、透明纱巾、逆光下的轮廓,每一个都是坑。嗯,咱们今天就把这些坑一个一个填上。
4.1 U-Net架构:为什么它适合人像分割?
U-Net是2015年提出的,但到现在还是很多分割任务的基线模型。我见过不少团队拿它做落地项目,效果稳得很。
它的核心思路就四个字:编码-解码。编码器下采样提取特征,解码器上采样恢复分辨率。中间加个跳跃连接,把浅层细节传到深层。
U-Net的三大设计亮点:
- 对称结构:左边下采样4次,右边上采样4次,像个U形
- 跳跃连接:把编码器的特征图直接拼到解码器对应层
- 深层监督:最后一层用1x1卷积输出类别数
为什么跳跃连接这么重要?你想想看,下采样会丢失空间信息——头发丝的位置、轮廓的细节,这些在深层特征里已经模糊了。跳跃连接相当于给解码器开了个"直通车",把原始分辨率的信息直接送过去。
我在项目中遇到过一个问题:用普通FCN做人像分割,边缘总是糊的。换成U-Net之后,边缘精度直接提升了5个点。说白了,跳跃连接就是干这个用的。
4.2 分割损失函数:不只是交叉熵
损失函数选不对,模型训练就是白费力气。我见过有人直接用交叉熵做人像分割,结果背景像素占了90%,模型学出来全是背景——说白了,类别不平衡搞的鬼。
这里我列出几个常用的分割损失函数,以及它们的适用场景:
| 损失函数 | 公式 | 适用场景 | 我的经验 |
|---|---|---|---|
| 交叉熵损失 | −∑ y log(p) | 类别均衡时 | 人像分割不推荐,背景占比太大 |
| Dice损失 | 1 − (2|A∩B|)/(|A|+|B|) | 小目标、类别不平衡 | 人像分割首选,边缘效果好 |
| Focal损失 | −α(1−p)^γ log(p) | 难样本挖掘 | 处理头发丝、透明物体时好用 |
| 边界损失 | 基于距离变换 | 边缘精度要求高 | 配合Dice使用,效果更稳 |
我的建议:做人像分割,直接用 Dice损失 + 交叉熵损失 的组合。Dice负责处理类别不平衡,交叉熵提供梯度稳定性。权重比可以设成0.7:0.3,我试过很多次,这个比例最稳。
为什么会这样?Dice损失对前景区域敏感,哪怕人像只占图像的10%,它也能给足权重。但Dice损失在训练初期梯度不稳定,加上交叉熵就好多了。
4.3 人像分割数据集:从哪里来,怎么用?
数据是算法的燃料。做人像分割,数据集的质量直接决定模型上限。我踩过最大的坑就是用了标注不精细的数据集——头发丝边缘全是锯齿,模型学出来边缘也是锯齿。
常用的公开数据集有这几个:
- Supervisely Person:5700张,标注精细,含头发丝
- EG1800:1800张,半身人像为主
- PFCN:1500张,全身人像,背景复杂
- LIP:50000张,人体部位分割,含人像
但说实话,公开数据集跟你的业务场景往往有差距。我建议你这样做:
- 先拿公开数据预训练:用Supervisely Person训一个基线模型
- 采集业务数据:至少500张,覆盖你的实际场景
- 人工精标:头发丝、手指缝、透明物体,这些地方必须逐像素标注
- 数据增强:随机裁剪、旋转、色彩抖动、高斯模糊
避坑指南:我曾经用了一个标注粗糙的数据集,模型在验证集上mIoU有0.95,但一上线就崩了——边缘全是锯齿。后来发现,标注人员把头发丝区域直接涂成了背景。所以,数据标注一定要抽检,至少抽20%的样本人工复核。
4.4 实战:用U-Net训练一个人像分割模型
咱们直接上代码。我用PyTorch实现一个轻量级U-Net,专门做人像分割。
import torch
import torch.nn as nn
import torch.nn.functional as F
class DoubleConv(nn.Module):
def __init__(self, in_ch, out_ch):
super().__init__()
self.conv = nn.Sequential(
nn.Conv2d(in_ch, out_ch, 3, padding=1),
nn.BatchNorm2d(out_ch),
nn.ReLU(inplace=True),
nn.Conv2d(out_ch, out_ch, 3, padding=1),
nn.BatchNorm2d(out_ch),
nn.ReLU(inplace=True)
)
def forward(self, x):
return self.conv(x)
class UNet(nn.Module):
def __init__(self, in_channels=3, out_channels=1):
super().__init__()
# 编码器
self.enc1 = DoubleConv(in_channels, 64)
self.enc2 = DoubleConv(64, 128)
self.enc3 = DoubleConv(128, 256)
self.enc4 = DoubleConv(256, 512)
# 瓶颈
self.bottleneck = DoubleConv(512, 1024)
# 解码器
self.up4 = nn.ConvTranspose2d(1024, 512, 2, stride=2)
self.dec4 = DoubleConv(1024, 512)
self.up3 = nn.ConvTranspose2d(512, 256, 2, stride=2)
self.dec3 = DoubleConv(512, 256)
self.up2 = nn.ConvTranspose2d(256, 128, 2, stride=2)
self.dec2 = DoubleConv(256, 128)
self.up1 = nn.ConvTranspose2d(128, 64, 2, stride=2)
self.dec1 = DoubleConv(128, 64)
# 输出
self.out = nn.Conv2d(64, out_channels, 1)
def forward(self, x):
# 编码
e1 = self.enc1(x)
e2 = self.enc2(F.max_pool2d(e1, 2))
e3 = self.enc3(F.max_pool2d(e2, 2))
e4 = self.enc4(F.max_pool2d(e3, 2))
# 瓶颈
b = self.bottleneck(F.max_pool2d(e4, 2))
# 解码 + 跳跃连接
d4 = self.up4(b)
d4 = torch.cat([d4, e4], dim=1)
d4 = self.dec4(d4)
d3 = self.up3(d4)
d3 = torch.cat([d3, e3], dim=1)
d3 = self.dec3(d3)
d2 = self.up2(d3)
d2 = torch.cat([d2, e2], dim=1)
d2 = self.dec2(d2)
d1 = self.up1(d2)
d1 = torch.cat([d1, e1], dim=1)
d1 = self.dec1(d1)
return torch.sigmoid(self.out(d1))
训练时,损失函数我用Dice + BCE的组合:
class DiceBCELoss(nn.Module):
def __init__(self, weight_dice=0.7, weight_bce=0.3):
super().__init__()
self.weight_dice = weight_dice
self.weight_bce = weight_bce
self.bce = nn.BCELoss()
def forward(self, pred, target):
# Dice损失
smooth = 1e-6
pred_flat = pred.view(-1)
target_flat = target.view(-1)
intersection = (pred_flat * target_flat).sum()
dice_loss = 1 - (2. * intersection + smooth) / (pred_flat.sum() + target_flat.sum() + smooth)
# BCE损失
bce_loss = self.bce(pred, target)
return self.weight_dice * dice_loss + self.weight_bce * bce_loss
训练参数建议:
- 输入尺寸:256x256(平衡精度和速度)
- Batch size:16(根据显存调整)
- 学习率:1e-4,用Adam优化器
- Epochs:50-100,早停法防止过拟合
- 数据增强:随机翻转、旋转±15°、色彩抖动
嗯,这套配置我用了很多项目,基本都能在20个epoch内收敛到mIoU 0.95以上。如果你发现训练不稳定,可以试试把学习率降到5e-5,或者加个学习率衰减。
最后说一句:U-Net虽然老,但够用。很多工业级人像分割系统,底层还是U-Net或者它的变体。先把基础打牢,后面再上DeepLabV3+、HRNet这些高级架构,你会理解得更深。