4、数据预处理与增强:图像读取与缩放、随机翻转与旋转、颜色抖动与归一化、自定义Dataset类与DataLoader

做语义分割,数据预处理这块儿,我个人的经验是——它比模型设计还容易翻车。你想想看,模型结构再花哨,喂进去的数据要是乱七八糟,训练出来的结果肯定也是乱七八糟。今天咱们就把这块硬骨头啃下来。

4.1 图像读取与缩放:别让尺寸成为绊脚石

图像读取这事儿,看似简单,其实坑不少。我刚开始做分割项目时,直接用OpenCV的imread,结果发现读出来的图像是BGR格式,而模型训练用的是RGB。嗯,这个细节不注意,训练出来的模型颜色全乱套。

注意: OpenCV默认读取BGR,PIL读取RGB。我个人习惯用PIL,省去转换的麻烦。

缩放就更讲究了。语义分割的标签图(mask)和原图必须用相同的缩放方式。原图可以用双线性插值,但标签图必须用最近邻插值。为什么?因为双线性插值会引入新的像素值,而标签图里每个像素代表一个类别,不能出现“中间值”。

from PIL import Image

def resize_image_and_mask(image, mask, target_size):
    # 原图用双线性插值
    image_resized = image.resize(target_size, Image.BILINEAR)
    # 标签图用最近邻插值
    mask_resized = mask.resize(target_size, Image.NEAREST)
    return image_resized, mask_resized

我曾经在项目里犯过这个错,标签图用了双线性插值,结果训练出来的模型在边缘处全是模糊的预测。排查了两天才找到原因,从那以后我再也不敢混用插值方式了。

4.2 随机翻转与旋转:数据增强的“免费午餐”

数据增强说白了就是让模型见多识广。随机翻转和旋转是最基础、最有效的两种方式。我建议水平翻转和垂直翻转都做,但要注意——图像和标签必须同步变换。

核心原则: 对图像做了什么变换,对标签图也要做完全相同的变换。

import random
import torchvision.transforms.functional as TF

def random_flip(image, mask, p=0.5):
    # 水平翻转
    if random.random() < p:
        image = TF.hflip(image)
        mask = TF.hflip(mask)
    # 垂直翻转
    if random.random() < p:
        image = TF.vflip(image)
        mask = TF.vflip(mask)
    return image, mask

旋转这块儿,我一般只做±10°的小角度旋转。为什么?因为大角度旋转会让图像边缘出现大量黑边,这些黑边会干扰模型学习。你想想看,模型本来该学道路、建筑的特征,结果学了一堆黑边,那能对吗?

小技巧: 旋转时可以用fill参数填充黑边,比如用像素均值填充,效果会好很多。

4.3 颜色抖动与归一化:让模型“看”得更清楚

颜色抖动,说白了就是给图像的颜色加点“随机扰动”。亮度、对比度、饱和度、色相,每个维度稍微变一变,模型就能学到更鲁棒的特征。我习惯用PyTorch的ColorJitter,参数设置如下:

from torchvision.transforms import ColorJitter

color_jitter = ColorJitter(
    brightness=0.2,   # 亮度变化范围
    contrast=0.2,     # 对比度变化范围
    saturation=0.2,   # 饱和度变化范围
    hue=0.1           # 色相变化范围
)

归一化这一步,很多人觉得就是减均值除方差,没什么好说的。但我要提醒你——均值和方差的计算方式很重要。我见过有人直接用ImageNet的均值和方差来归一化自己的数据集,结果训练出来的模型效果很差。

数据集 均值 (RGB) 方差 (RGB)
ImageNet [0.485, 0.456, 0.406] [0.229, 0.224, 0.225]
Cityscapes [0.2869, 0.3251, 0.2839] [0.1761, 0.1810, 0.1777]
自定义数据集 建议自己计算 建议自己计算

我个人建议:如果数据集和ImageNet差异很大(比如遥感图像、医学图像),一定要自己算均值和方差。计算方式很简单,遍历所有训练图像,累加像素值和像素值的平方,最后求平均即可。

4.4 自定义Dataset类:把流程串起来

好了,前面这些操作都讲完了,现在要把它们串成一个完整的Dataset类。我习惯这么组织代码:

from torch.utils.data import Dataset
import os
from PIL import Image

class SegmentationDataset(Dataset):
    def __init__(self, image_dir, mask_dir, transform=None):
        self.image_dir = image_dir
        self.mask_dir = mask_dir
        self.transform = transform
        self.images = os.listdir(image_dir)
    
    def __len__(self):
        return len(self.images)
    
    def __getitem__(self, idx):
        img_name = self.images[idx]
        img_path = os.path.join(self.image_dir, img_name)
        mask_path = os.path.join(self.mask_dir, img_name)
        
        image = Image.open(img_path).convert('RGB')
        mask = Image.open(mask_path)
        
        if self.transform:
            image, mask = self.transform(image, mask)
        
        return image, mask

这里有个细节:__getitem__里返回的是PIL图像还是Tensor?我建议在Dataset里只做图像层面的变换(翻转、旋转、颜色抖动),把归一化和转Tensor的操作放到DataLoader的collate_fn里。这样代码更清晰,也方便调试。

4.5 DataLoader:数据加载的“调度中心”

DataLoader的参数设置,直接影响训练速度和内存占用。我常用的配置如下:

from torch.utils.data import DataLoader

dataloader = DataLoader(
    dataset,
    batch_size=8,
    shuffle=True,
    num_workers=4,      # 数据加载的进程数
    pin_memory=True,    # 加速GPU数据传输
    drop_last=True      # 丢弃最后一个不完整的batch
)

num_workers这个参数,我建议设置为CPU核心数的一半。设得太高反而会因为进程切换开销导致变慢。我曾经在32核的机器上设了32个worker,结果数据加载比设4个worker还慢。嗯,这个坑我替你们踩过了。

避坑指南: 如果训练时出现“DataLoader worker exited unexpectedly”的错误,多半是num_workers设得太高,或者__getitem__里有未捕获的异常。

4.6 完整的数据预处理流程

最后,我把整个流程串起来,画了一张流程图,方便你理解各个环节的关系:

MobileViT语义分割数据预处理流程 原始图像 + 标签 图像读取与缩放(双线性 vs 最近邻) 数据增强:随机翻转 + 随机旋转 + 颜色抖动 归一化(减均值除方差) DataLoader 批量加载 关键注意事项 1. 图像与标签同步变换 2. 标签图用最近邻插值 3. 旋转角度不宜过大 4. 颜色抖动参数要适中 5. 归一化用数据集自身统计量 6. num_workers设为CPU核心数一半 7. pin_memory=True加速传输 8. drop_last避免batch大小不一致

整个流程走下来,你会发现数据预处理其实是个系统工程。每个环节都有它的道理,少一个都不行。我个人习惯把所有的变换操作封装成一个Compose类,这样代码看起来清爽,也方便复用。

我的建议: 刚开始做项目时,先不要加太多数据增强。等模型能正常收敛了,再逐步增加增强策略。这样出了问题也好排查。

好了,数据预处理这块就讲到这里。记住一句话:数据质量决定了模型性能的上限。把数据处理好,模型训练就成功了一半。

可可精品资料库