4、数据预处理与增强:图像读取与缩放、随机翻转与旋转、颜色抖动与归一化、自定义Dataset类与DataLoader
做语义分割,数据预处理这块儿,我个人的经验是——它比模型设计还容易翻车。你想想看,模型结构再花哨,喂进去的数据要是乱七八糟,训练出来的结果肯定也是乱七八糟。今天咱们就把这块硬骨头啃下来。
4.1 图像读取与缩放:别让尺寸成为绊脚石
图像读取这事儿,看似简单,其实坑不少。我刚开始做分割项目时,直接用OpenCV的imread,结果发现读出来的图像是BGR格式,而模型训练用的是RGB。嗯,这个细节不注意,训练出来的模型颜色全乱套。
缩放就更讲究了。语义分割的标签图(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 完整的数据预处理流程
最后,我把整个流程串起来,画了一张流程图,方便你理解各个环节的关系:
整个流程走下来,你会发现数据预处理其实是个系统工程。每个环节都有它的道理,少一个都不行。我个人习惯把所有的变换操作封装成一个Compose类,这样代码看起来清爽,也方便复用。
我的建议: 刚开始做项目时,先不要加太多数据增强。等模型能正常收敛了,再逐步增加增强策略。这样出了问题也好排查。
好了,数据预处理这块就讲到这里。记住一句话:数据质量决定了模型性能的上限。把数据处理好,模型训练就成功了一半。