4、图像预处理:数据增强(翻转、旋转、裁剪)、归一化与标准化、tf.data构建图像流水线

各位同学,欢迎来到第四章。说实话,图像预处理这块,是我个人觉得整个多模态课程里最「接地气」的一章。为什么?因为不管你模型多牛,数据进嘴之前没处理好,后面全是白搭。我在项目里见过太多人,模型结构抄得飞起,结果训练集和验证集的预处理逻辑不一样,最后精度死活上不去——嗯,这种坑我踩过,今天咱们就把它填平。

4.1 数据增强:让模型「见多识广」

数据增强说白了就是「无中生有」。你手头只有1000张猫图,但通过翻转、旋转、裁剪,你能变出10000张。模型没见过这么多姿势,自然学得更鲁棒。

我个人习惯把增强分成两类:

  • 几何变换:翻转、旋转、裁剪、缩放
  • 颜色变换:亮度、对比度、饱和度、色相

在TensorFlow里,这些操作都在 tf.image 模块下。咱们一个个看。

4.1.1 翻转与旋转

翻转是最简单的增强。水平翻转对大多数物体识别任务都有效,但垂直翻转要小心——你想想看,把一张人脸上下颠倒,这合理吗?

import tensorflow as tf

# 读取一张图片
image = tf.io.read_file('cat.jpg')
image = tf.image.decode_jpeg(image, channels=3)

# 水平翻转(左右)
flipped_left_right = tf.image.flip_left_right(image)

# 垂直翻转(上下)—— 慎用
flipped_up_down = tf.image.flip_up_down(image)

# 随机翻转(训练时用)
random_flip = tf.image.random_flip_left_right(image, seed=42)
我的经验:随机翻转的seed一定要固定,否则每次跑结果不一样,调试起来很痛苦。我曾经因为这个浪费了半天。

旋转稍微复杂一点。TensorFlow提供了 tf.image.rot90,但只能旋转90度的整数倍。如果你需要任意角度,得用 tfa.image.rotate(TensorFlow Addons)。

# 90度旋转
rotated_90 = tf.image.rot90(image, k=1)  # k=1,2,3 分别对应90,180,270度

# 随机旋转(需要tensorflow-addons)
import tensorflow_addons as tfa
angles = tf.random.uniform([1], minval=-0.2, maxval=0.2)  # 弧度
random_rotated = tfa.image.rotate(image, angles, fill_mode='nearest')
注意:旋转后会出现黑色区域。用 fill_mode='reflect''nearest' 可以缓解,但最好配合裁剪一起用。

4.1.2 裁剪与缩放

裁剪有两种思路:中心裁剪和随机裁剪。中心裁剪适合验证集,随机裁剪适合训练集。

# 中心裁剪
cropped_center = tf.image.central_crop(image, central_fraction=0.8)

# 随机裁剪并缩放到固定大小
def random_crop_and_resize(image, target_size=(224, 224)):
    # 先随机裁剪一个区域
    crop_size = tf.random.uniform([], minval=0.7, maxval=1.0) * tf.cast(tf.shape(image)[0], tf.float32)
    crop_size = tf.cast(crop_size, tf.int32)
    cropped = tf.image.random_crop(image, [crop_size, crop_size, 3])
    # 再缩放到目标大小
    resized = tf.image.resize(cropped, target_size)
    return resized

这里有个细节:tf.image.random_crop 要求你指定裁剪后的尺寸,但如果你想要「随机比例裁剪」,得自己算。我一般用 tf.image.stateless_random_crop,配合 seed 参数,可复现性更好。

4.2 归一化与标准化

这两个概念经常被混用,我简单区分一下:

操作 公式 作用
归一化 x / 255.0 将像素值从[0,255]映射到[0,1]
标准化 (x - mean) / std 使数据分布接近标准正态分布

归一化是必须的。为什么?因为神经网络对输入尺度敏感。你想想看,如果输入范围是0~255,权重初始化一般是0~1,这梯度一算,直接爆炸。我刚开始学的时候没做归一化,loss直接变成NaN,查了半天才发现是这个问题。

# 简单归一化
normalized = tf.cast(image, tf.float32) / 255.0

# 标准化(使用ImageNet的均值和标准差)
mean = tf.constant([0.485, 0.456, 0.406])
std = tf.constant([0.229, 0.224, 0.225])
standardized = (normalized - mean) / std
关键点:标准化用的均值和标准差,必须和预训练模型保持一致。如果你用ResNet预训练权重,就得用ImageNet的统计值。别问我怎么知道的——我曾经用自己算的均值去跑预训练模型,精度掉了5个点。

4.3 用tf.data构建图像流水线

好了,前面都是单个操作。现在咱们要把它们串起来,组成一个高效的流水线。这就是 tf.data 的强项。

4.3.1 从文件路径构建Dataset

# 假设你的图片路径列表
file_paths = ['cat1.jpg', 'cat2.jpg', 'dog1.jpg', ...]
labels = [0, 0, 1, ...]  # 0:猫, 1:狗

# 构建Dataset
dataset = tf.data.Dataset.from_tensor_slices((file_paths, labels))

# 定义加载和预处理函数
def load_and_preprocess(file_path, label):
    # 读取图片
    image = tf.io.read_file(file_path)
    image = tf.image.decode_jpeg(image, channels=3)
    # 统一尺寸
    image = tf.image.resize(image, [224, 224])
    # 归一化
    image = tf.cast(image, tf.float32) / 255.0
    return image, label

# 应用预处理
dataset = dataset.map(load_and_preprocess, num_parallel_calls=tf.data.AUTOTUNE)

4.3.2 训练时加入数据增强

注意:增强只在训练时做,验证集不做。所以通常我们会定义两个不同的流水线。

# 训练集增强函数
def train_augment(image, label):
    # 随机翻转
    image = tf.image.random_flip_left_right(image)
    # 随机亮度调整
    image = tf.image.random_brightness(image, max_delta=0.2)
    # 随机对比度调整
    image = tf.image.random_contrast(image, lower=0.8, upper=1.2)
    return image, label

# 验证集只做基本预处理
def val_preprocess(image, label):
    return image, label

# 构建训练流水线
train_dataset = dataset.map(load_and_preprocess)
train_dataset = train_dataset.map(train_augment)
train_dataset = train_dataset.shuffle(buffer_size=1000)
train_dataset = train_dataset.batch(32)
train_dataset = train_dataset.prefetch(tf.data.AUTOTUNE)

# 构建验证流水线
val_dataset = dataset.map(load_and_preprocess)
val_dataset = val_dataset.map(val_preprocess)
val_dataset = val_dataset.batch(32)
val_dataset = val_dataset.prefetch(tf.data.AUTOTUNE)
性能优化:prefetch(tf.data.AUTOTUNE) 一定要加。它让CPU在GPU处理当前batch时,提前准备下一个batch。不加的话,GPU会经常「空转」,训练速度慢30%以上。

4.3.3 完整的流水线示例

我把上面所有内容整合成一个完整的函数,方便你直接复用:

def build_image_pipeline(file_paths, labels, batch_size=32, is_training=True):
    dataset = tf.data.Dataset.from_tensor_slices((file_paths, labels))
    
    def load_and_preprocess(file_path, label):
        image = tf.io.read_file(file_path)
        image = tf.image.decode_jpeg(image, channels=3)
        image = tf.image.resize(image, [224, 224])
        image = tf.cast(image, tf.float32) / 255.0
        return image, label
    
    dataset = dataset.map(load_and_preprocess, num_parallel_calls=tf.data.AUTOTUNE)
    
    if is_training:
        # 训练增强
        def augment(image, label):
            image = tf.image.random_flip_left_right(image)
            image = tf.image.random_brightness(image, 0.2)
            image = tf.image.random_contrast(image, 0.8, 1.2)
            # 随机裁剪缩放(模拟尺度变化)
            if tf.random.uniform([]) > 0.5:
                image = tf.image.central_crop(image, 0.8)
                image = tf.image.resize(image, [224, 224])
            return image, label
        
        dataset = dataset.map(augment, num_parallel_calls=tf.data.AUTOTUNE)
        dataset = dataset.shuffle(1000)
    
    dataset = dataset.batch(batch_size)
    dataset = dataset.prefetch(tf.data.AUTOTUNE)
    
    return dataset

# 使用
train_ds = build_image_pipeline(train_paths, train_labels, is_training=True)
val_ds = build_image_pipeline(val_paths, val_labels, is_training=False)

4.4 避坑指南

最后,分享几个我踩过的坑:

  • 增强顺序很重要:先裁剪再缩放,不要先缩放再裁剪。否则裁剪的区域分辨率不够,图像会模糊。
  • 不要对验证集做随机增强:验证集需要确定性结果。我曾经不小心把随机翻转加到验证流水线里,结果每次评估结果都不一样,还以为模型没收敛。
  • 注意数据类型tf.image.decode_jpeg 输出是 uint8,做归一化前要转成 float32。否则除以255会变成整数除法,结果全是0。
  • 缓存预处理结果:如果数据集不大,可以用 .cache() 把预处理后的结果缓存到内存或磁盘,避免每次epoch都重新读取和解码。
# 缓存示例
dataset = dataset.map(load_and_preprocess).cache()  # 缓存到内存
# 或者缓存到文件
dataset = dataset.map(load_and_preprocess).cache('preprocessed_cache')

好了,这一章的内容就到这里。图像预处理看似基础,但细节决定成败。下一章我们会进入文本数据的处理,到时候你会发现,文本的「预处理」比图像要麻烦得多——嗯,咱们到时候再聊。