Building an image augmentation pipeline in TensorFlow lets the training input stage generate fresh image variants without rewriting the source files on disk. This keeps the model exposed to flips, rotations, and zoom changes while the labels and dataset split stay stable.
A tf.data.Dataset pipeline separates deterministic work such as resizing and rescaling from training-only random transforms. Current Keras preprocessing layers such as RandomFlip, RandomRotation, and RandomZoom can run inside a mapped dataset batch when they are called with training=True, and prefetch() keeps those augmented batches queued for the training loop.
The cache boundary matters in this workflow. If cache() is placed after the random augmentation stage, later epochs reuse the same transformed pixels instead of generating new variants, while validation and test datasets should stay on the deterministic preprocessing path so their metrics measure the real model state instead of a moving random target.
$ python3 -c "import tensorflow as tf; print(tf.__version__); print(tf.keras.__version__)" 2.21.0 3.15.0
import os os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2" import tensorflow as tf layers = tf.keras.layers tf.get_logger().setLevel("ERROR") tf.keras.utils.set_random_seed(7) IMAGE_SIZE = 64 BATCH_SIZE = 4 def make_image(index): index = tf.cast(index, tf.float32) grid = tf.linspace(0.0, 1.0, 48) xx, yy = tf.meshgrid(grid, grid) xx = tf.expand_dims(xx, -1) yy = tf.expand_dims(yy, -1) diagonal = tf.cast(xx > yy, tf.float32) image = tf.concat( [ tf.clip_by_value(xx + index * 0.04, 0.0, 1.0), tf.clip_by_value(1.0 - yy * 0.8, 0.0, 1.0), tf.clip_by_value(diagonal * 0.7 + index * 0.02, 0.0, 1.0), ], axis=-1, ) return tf.cast(image * 255.0, tf.float32) images = tf.stack([make_image(i) for i in range(8)], axis=0) labels = tf.constant([0, 1, 0, 1, 0, 1, 0, 1], dtype=tf.int32) resize_and_rescale = tf.keras.Sequential( [ layers.Resizing(IMAGE_SIZE, IMAGE_SIZE), layers.Rescaling(1.0 / 255.0), ] ) augment = tf.keras.Sequential( [ layers.RandomFlip("horizontal", seed=7), layers.RandomRotation(0.15, seed=7), layers.RandomZoom(0.1, seed=7), ] ) def prepare(image, label): image = resize_and_rescale(image) return image, label base_ds = ( tf.data.Dataset.from_tensor_slices((images, labels)) .map(prepare, num_parallel_calls=tf.data.AUTOTUNE) .cache() ) train_ds = ( base_ds .shuffle(len(labels), seed=7, reshuffle_each_iteration=False) .batch(BATCH_SIZE) ) train_aug_ds = train_ds.map( lambda x, y: (augment(x, training=True), y), num_parallel_calls=tf.data.AUTOTUNE, ).prefetch(tf.data.AUTOTUNE) val_ds = base_ds.batch(BATCH_SIZE).prefetch(tf.data.AUTOTUNE) first_train_images, first_train_labels = next(iter(train_aug_ds)) second_train_images, _ = next(iter(train_aug_ds)) val_images, _ = next(iter(val_ds)) base_images, _ = next(iter(base_ds.batch(BATCH_SIZE))) pixel_delta = float(tf.reduce_sum(tf.abs(first_train_images - second_train_images)).numpy()) val_matches_base = bool(tf.reduce_all(tf.abs(val_images - base_images) < 1e-6).numpy()) print(f"TensorFlow {tf.__version__}") print(f"Keras {tf.keras.__version__}") print(f"train_batch_shape={tuple(first_train_images.shape)}") print(f"train_labels={first_train_labels.numpy().tolist()}") print(f"augmentation_changes_pixels={pixel_delta > 0.0}") print(f"validation_matches_base={val_matches_base}") print( "pixel_value_range=" f"({float(tf.reduce_min(first_train_images).numpy()):.3f}, " f"{float(tf.reduce_max(first_train_images).numpy()):.3f})" )
The demo fixes reshuffle_each_iteration=False so the comparison proves the augmentation effect instead of a different batch order. Restore the default reshuffle behavior in real training when each epoch should also see a new image order.
Related: How to set a random seed in TensorFlow
$ python3 image-augmentation-pipeline.py TensorFlow 2.21.0 Keras 3.15.0 train_batch_shape=(4, 64, 64, 3) train_labels=[0, 1, 1, 0] augmentation_changes_pixels=True validation_matches_base=True pixel_value_range=(0.000, 1.000)
augmentation_changes_pixels=True shows that the training path applies random transforms, and validation_matches_base=True shows that the validation path keeps the cached deterministic images.
AUTOTUNE = tf.data.AUTOTUNE train_ds = ( train_raw_ds .map(lambda x, y: (resize_and_rescale(x), y), num_parallel_calls=AUTOTUNE) .cache() .map(lambda x, y: (augment(x, training=True), y), num_parallel_calls=AUTOTUNE) .prefetch(AUTOTUNE) ) val_ds = ( val_raw_ds .map(lambda x, y: (resize_and_rescale(x), y), num_parallel_calls=AUTOTUNE) .cache() .prefetch(AUTOTUNE) ) model.fit(train_ds, validation_data=val_ds, epochs=10)
Do not cache after the random augmentation map unless the goal is to freeze one augmented copy of each image. Caching after augmentation stops later epochs from seeing new flips, rotations, or zoom values.
$ rm image-augmentation-pipeline.py