4. 语义分割入门

语义分割,说白了就是给图像里的每个像素打标签。你拍一张人像,模型得知道哪些像素属于人,哪些属于背景。这跟目标检测不一样——检测只画个框,分割要精确到发丝边缘。

我刚开始做人像分割时,总觉得这事儿挺简单。后来发现,头发丝、透明纱巾、逆光下的轮廓,每一个都是坑。嗯,咱们今天就把这些坑一个一个填上。

4.1 U-Net架构:为什么它适合人像分割?

U-Net是2015年提出的,但到现在还是很多分割任务的基线模型。我见过不少团队拿它做落地项目,效果稳得很。

它的核心思路就四个字:编码-解码。编码器下采样提取特征,解码器上采样恢复分辨率。中间加个跳跃连接,把浅层细节传到深层。

U-Net的三大设计亮点:

  • 对称结构:左边下采样4次,右边上采样4次,像个U形
  • 跳跃连接:把编码器的特征图直接拼到解码器对应层
  • 深层监督:最后一层用1x1卷积输出类别数

为什么跳跃连接这么重要?你想想看,下采样会丢失空间信息——头发丝的位置、轮廓的细节,这些在深层特征里已经模糊了。跳跃连接相当于给解码器开了个"直通车",把原始分辨率的信息直接送过去。

我在项目中遇到过一个问题:用普通FCN做人像分割,边缘总是糊的。换成U-Net之后,边缘精度直接提升了5个点。说白了,跳跃连接就是干这个用的。

U-Net 架构示意图 输入 256x256 Conv+ReLU 下采样 Conv+ReLU 下采样 Conv+ReLU 瓶颈层 上采样 Conv+ReLU 上采样 Conv+ReLU 上采样 Conv+ReLU 输出 256x256 跳跃连接的作用 将编码器特征图 拼接到解码器对应层 保留空间细节信息 编码-解码结构 编码器提取语义特征 解码器恢复空间分辨率

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张,人体部位分割,含人像

但说实话,公开数据集跟你的业务场景往往有差距。我建议你这样做:

  1. 先拿公开数据预训练:用Supervisely Person训一个基线模型
  2. 采集业务数据:至少500张,覆盖你的实际场景
  3. 人工精标:头发丝、手指缝、透明物体,这些地方必须逐像素标注
  4. 数据增强:随机裁剪、旋转、色彩抖动、高斯模糊

避坑指南:我曾经用了一个标注粗糙的数据集,模型在验证集上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这些高级架构,你会理解得更深。