4. 基于自训练的域适应

任务说明

本案例面向语义分割的Sim2Real域适应问题:在合成数据(源域)上训练的分割模型,直接应用于真实图像(目标域)时精度显著下降。目标是通过自训练(Self-training)方法,利用模型自身对真实图像生成的伪标签进行迭代微调,从而提升模型在真实数据上的分割精度。案例使用轻量级UNet作为分割网络,在简化版合成与真实数据集上完成完整闭环。

实现方案

整体流程分为三个阶段:源域预训练 → 伪标签生成 → 目标域自训练微调。核心思想是:先用合成数据训练一个初始模型;随后用该模型对无标注的真实图像进行预测,取高置信度像素作为伪标签;最后用伪标签以监督学习方式微调模型,重复该过程以逐步提升域适应能力。为控制复杂度,本案例使用合成数据模拟器生成带噪矩形图像作为源域,使用带风格偏移的同类图像作为目标域,并仅对“矩形区域”这一类进行二分类分割。

代码实现

import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
from torch.utils.data import Dataset, DataLoader

# ---------- 1. 合成数据生成器 ----------
def generate_synthetic_images(num_samples, img_size=64, noise_level=0.1, style_shift=0.0):
    """生成带矩形前景的合成图像及对应二值掩码。
    style_shift>0时模拟目标域风格偏移(亮度/对比度变化)。"""
    images, masks = [], []
    for _ in range(num_samples):
        img = np.random.rand(img_size, img_size, 3).astype(np.float32)
        mask = np.zeros((img_size, img_size), dtype=np.int64)
        # 随机矩形
        x0, y0 = np.random.randint(0, img_size//2, 2)
        w = np.random.randint(img_size//4, img_size//2)
        h = np.random.randint(img_size//4, img_size//2)
        x1, y1 = min(x0+w, img_size), min(y0+h, img_size)
        img[y0:y1, x0:x1] = 0.8  # 矩形区域设为亮色
        mask[y0:y1, x0:x1] = 1
        # 添加噪声与风格偏移
        img += np.random.normal(0, noise_level, img.shape).astype(np.float32)
        if style_shift > 0:
            img = img * (1 + style_shift) + 0.1 * style_shift
        images.append(np.clip(img, 0, 1))
        masks.append(mask)
    return np.stack(images), np.stack(masks)

# ---------- 2. 数据集封装 ----------
class SegDataset(Dataset):
    def __init__(self, images, masks=None):
        self.images = torch.FloatTensor(images).permute(0, 3, 1, 2)
        self.masks = torch.LongTensor(masks) if masks is not None else None

    def __len__(self):
        return len(self.images)

    def __getitem__(self, idx):
        if self.masks is not None:
            return self.images[idx], self.masks[idx]
        return self.images[idx]

# ---------- 3. 轻量UNet ----------
class LightUNet(nn.Module):
    def __init__(self, in_ch=3, out_ch=2):
        super().__init__()
        self.enc1 = nn.Sequential(nn.Conv2d(in_ch, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2))
        self.enc2 = nn.Sequential(nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2))
        self.dec = nn.Sequential(
            nn.ConvTranspose2d(32, 16, 2, stride=2),
            nn.ReLU(),
            nn.ConvTranspose2d(16, out_ch, 2, stride=2)
        )

    def forward(self, x):
        x = self.enc1(x)
        x = self.enc2(x)
        return self.dec(x)

# ---------- 4. 训练与评估函数 ----------
def train_one_epoch(model, loader, optimizer, criterion, device):
    model.train()
    total_loss = 0
    for imgs, masks in loader:
        imgs, masks = imgs.to(device), masks.to(device)
        optimizer.zero_grad()
        out = model(imgs)
        loss = criterion(out, masks)
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    return total_loss / len(loader)

def compute_iou(model, loader, device):
    """计算mIoU(仅对前景类)。"""
    model.eval()
    intersection, union = 0, 0
    with torch.no_grad():
        for imgs, masks in loader:
            imgs, masks = imgs.to(device), masks.to(device)
            pred = model(imgs).argmax(dim=1)
            inter = ((pred == 1) & (masks == 1)).sum().item()
            un = ((pred == 1) | (masks == 1)).sum().item()
            intersection += inter
            union += un
    return intersection / (union + 1e-6)

# ---------- 5. 主流程:自训练域适应 ----------
def main():
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    torch.manual_seed(42)
    np.random.seed(42)

    # 数据准备
    src_imgs, src_masks = generate_synthetic_images(200, noise_level=0.05, style_shift=0.0)
    tgt_imgs, tgt_masks = generate_synthetic_images(100, noise_level=0.15, style_shift=0.5)  # 目标域带偏移
    # 目标域仅使用图像(无标注),掩码仅用于最终评估
    src_loader = DataLoader(SegDataset(src_imgs, src_masks), batch_size=16, shuffle=True)
    tgt_unlabeled = DataLoader(SegDataset(tgt_imgs), batch_size=16, shuffle=False)
    tgt_eval_loader = DataLoader(SegDataset(tgt_imgs, tgt_masks), batch_size=16, shuffle=False)

    # 模型与优化器
    model = LightUNet().to(device)
    optimizer = optim.Adam(model.parameters(), lr=1e-3)
    criterion = nn.CrossEntropyLoss()

    # 阶段1:源域预训练
    print("=== 阶段1: 源域预训练 ===")
    for epoch in range(10):
        loss = train_one_epoch(model, src_loader, optimizer, criterion, device)
    src_iou = compute_iou(model, src_loader, device)
    tgt_iou_before = compute_iou(model, tgt_eval_loader, device)
    print(f"源域mIoU: {src_iou:.4f} | 目标域初始mIoU: {tgt_iou_before:.4f}")

    # 阶段2-3:伪标签生成 + 自训练微调(迭代2轮)
    print("=== 阶段2/3: 伪标签自训练 ===")
    for iteration in range(2):
        # 生成伪标签
        model.eval()
        pseudo_masks = []
        with torch.no_grad():
            for imgs in tgt_unlabeled:
                imgs = imgs.to(device)
                probs = torch.softmax(model(imgs), dim=1)
                conf, pred = probs.max(dim=1)
                # 仅保留高置信度像素(阈值0.8),其余设为忽略索引-100
                pseudo = torch.where(conf > 0.8, pred, torch.full_like(pred, -100))
                pseudo_masks.append(pseudo.cpu())
        pseudo_masks = torch.cat(pseudo_masks, dim=0).numpy()

        # 用伪标签微调
        pseudo_dataset = SegDataset(tgt_imgs, pseudo_masks)
        pseudo_loader = DataLoader(pseudo_dataset, batch_size=16, shuffle=True)
        for epoch in range(5):
            train_one_epoch(model, pseudo_loader, optimizer, criterion, device)

        tgt_iou_after = compute_iou(model, tgt_eval_loader, device)
        print(f"迭代{iteration+1}后目标域mIoU: {tgt_iou_after:.4f}")

    print("自训练完成")

if __name__ == "__main__":
    main()

关键实现说明

  • 伪标签置信度过滤:代码中通过 torch.where(conf > 0.8, pred, -100) 实现,低于阈值的像素在CrossEntropyLoss中默认被忽略(ignore_index=-100),防止低质量伪标签误导训练。
  • 迭代自训练:主循环执行2轮“生成伪标签→微调”过程,每轮微调5个epoch,在保持训练效率的同时验证精度提升趋势。
  • 数据划分:源域200张带噪图像,目标域100张带风格偏移图像。目标域掩码仅用于最终评估,训练过程中模型从未接触目标域真实标注。

运行与验证

运行方式:将代码保存为 self_training_da.py,在安装有PyTorch(≥1.8)和NumPy的环境中执行 python self_training_da.py 即可。

验证方法:观察控制台输出的mIoU变化。判定标准为:目标域初始mIoU(阶段1后)应明显低于源域mIoU(通常低0.2以上),而经过2轮自训练后,目标域mIoU应逐步提升并接近源域水平。由于随机种子固定为42,每次运行结果可复现。若目标域初始mIoU与源域差距过小,可增大style_shift至0.8增强域偏移;若提升不明显,可增加自训练迭代轮数至3-4轮。

自训练域适应流程(Self-training for Sim2Real) 阶段1:源域预训练 合成图像 + 真实掩码 ↓ 训练LightUNet 得到初始模型 M₀ 阶段2:伪标签生成 真实图像(无标注) ↓ 模型M₀前向推理 Softmax概率 置信度 > 0.8 → 伪标签 低置信度像素忽略 阶段3:微调 伪标签作为监督 ↓ 更新模型参数 得到新模型 M₁ 迭代2轮:M₁ → 新伪标签 → M₂ 验证:目标域真实掩码 vs 模型预测 计算mIoU(仅前景类) 判定通过:目标域mIoU随迭代逐步提升,且高于初始值 (源域mIoU > 目标域初始mIoU > 自训练后mIoU)

结果判定与说明

运行脚本后,控制台会依次输出源域mIoU、目标域初始mIoU以及每轮自训练后的目标域mIoU。由于代码中固定了随机种子,每次运行结果一致。典型的成功表现为:源域mIoU在0.85以上,目标域初始mIoU因风格偏移降至0.5-0.7,经过2轮自训练后回升至0.75以上。若未达到该趋势,请检查目标域风格偏移参数style_shift是否生效,或适当增加自训练轮次与微调epoch数。本案例完整实现了“合成数据预训练→伪标签生成→置信度过滤→目标域微调”的自训练闭环,可直接作为Sim2Real语义分割域适应的最小可复现基线。