Data Pipelines and Augmentation for Vision Models
In this series (30 parts)
- Computer Vision Roadmap: From Basics to Real Projects
- What is Computer Vision? From Pixels to Decisions
- A Short History of Computer Vision: 1545 to Now
- Math for CV Beginners: Vectors, Matrices, Convolutions
- Image Fundamentals: Color, Histograms, Noise, Filtering
- How Images and Video Are Stored: Colour, JPEG, Frames
- OpenCV Setup + First 10 Tasks in Python
- Vision Metrics: Accuracy, Precision, Recall, mAP, IoU
- CV Project Workflow: Dataset, Baseline, Error Iteration
- Intensity Transforms and Frequency-Domain Filtering
- Edge Detection and Thresholding That Actually Work
- Morphology, Contours, and Shape Analysis for Real Images
- Feature Matching (SIFT/ORB) and Image Stitching
- Camera Calibration and Perspective Correction
- Epipolar Geometry, Stereo Vision, and Depth Estimation
- Optical Flow and Motion Tracking in Video
- HOG, HOF and MBH: Descriptors Before Deep Learning
- Your First Image Classifier (PyTorch + Transfer Learning)
- Data Pipelines and Augmentation for Vision Models
- CNN Architectures Explained: From LeNet to ResNet
- YOLO Detection Pipeline: Data to Inference
- Semantic and Instance Segmentation: U-Net to Mask R-CNN
- Vision Transformers (ViT) and When to Use Them
- CV Explainability: Grad-CAM, Failures, Bias Checks
- 3D Vision Basics: SfM, Point Clouds, and Pose Estimation
- Multimodal Vision: CLIP, Embeddings, and Retrieval Systems
- Video Understanding: Flow Networks, Interpolation, Stabilisation
- Real-Time CV Systems: Tracking, Latency, Streaming
- Deploying CV Models: ONNX, TensorRT, Edge, and APIs
- Responsible CV: Privacy, Fairness, Security, Governance
The classifier in the last post started overfitting at epoch 10. The usual first response is augmentation — but a badly chosen augmentation makes things worse, and some quietly destroy your labels. This post covers which ones help, which ones lie, and how to keep the GPU fed.
Prerequisites: Your first image classifier.
What augmentation actually claims
augmentation Randomly transforming training images in ways that should not change their label, so the model sees more variety than you collected.The definition contains the trap. Every augmentation is a claim: this change does not alter the correct answer. When the claim is true, you get free training data. When it is false, you are training the model on wrong labels.
| # | Augmentation | The claim it makes | True for recycling photos? target | True for reading gauges? |
|---|---|---|---|---|
| 1 | Horizontal flip | A mirrored object is the same object | yes | no — digits become mirrored |
| 2 | Vertical flip | An upside-down object is the same object | mostly | no |
| 3 | Rotation ±15° | A tilted object is the same object | yes | no — needle angle is the answer |
| 4 | Colour jitter | Different lighting, same object | yes | depends |
| 5 | Hue shift | Different colour, same object | no — glass vs plastic uses tint | no |
| 6 | Random crop 70–100% | A partly visible object is the same object | yes | risky |
| 7 | Gaussian blur | A slightly soft photo is the same object | yes | no if reading small text |
The same augmentation is correct for one task and wrong for another. There is no universal list.
Look at the hue shift row. In the recycling classifier, the glass–plastic confusion was the dominant error, and the little colour tint each material gives is one of the few real cues. Shifting hue randomly deletes that cue and makes the worst confusion worse.
Watch a crop go wrong
RandomResizedCrop is the single most effective augmentation for classification and the easiest to misconfigure. Its scale parameter is the fraction of the original area kept.
PyTorch’s default is scale=(0.08, 1.0). On a 500×500 photo, 8% of the area is px², a region of about 141×141 pixels, which is then upscaled to 224×224.
The third panel is a training example labelled “plastic” that contains no plastic. Feed the model enough of those and you teach it that empty background predicts plastic.
| # | scale range | Minimum area kept | Object usually visible? target | Use for |
|---|---|---|---|---|
| 1 | (0.08, 1.0) | 8% | often not | ImageNet-scale training, millions of images |
| 2 | (0.5, 1.0) | 50% | yes | Small datasets — a safe default |
| 3 | (0.7, 1.0) | 70% | almost always | Small objects, or fine detail matters |
| 4 | (0.9, 1.0) | 90% | always | Barely augments — use with other transforms |
Choosing scale. The aggressive default assumes you have millions of images to average the damage away.
The default works for ImageNet because with 1.2 million images, occasional garbage crops are diluted. With 840 training images they are a meaningful fraction of every epoch.
Measuring what each augmentation is worth
Do not guess. Add one at a time and measure against a fixed validation set.
| # | Configuration | Train acc | Val acc target | Gap | Change vs previous |
|---|---|---|---|---|---|
| 1 | No augmentation | 99.8% | 78.3% | 21.5 | baseline |
| 2 | + RandomResizedCrop(0.7, 1.0) | 96.2% | 84.1% | 12.1 | +5.8 |
| 3 | + HorizontalFlip | 95.4% | 85.9% | 9.5 | +1.8 |
| 4 | + ColorJitter(0.3, 0.3) | 94.1% | 88.2% | 5.9 | +2.3 |
| 5 | + Rotation(±15°) | 93.6% | 88.7% | 4.9 | +0.5 |
| 6 | + Hue shift(±0.1) | 92.8% | 86.4% | 6.4 | −2.3 |
| 7 | + RandomErasing(p=0.25) | 91.9% | 89.4% | 2.5 | +0.7 (hue removed) |
One augmentation at a time on the recycling dataset. Hue shift is the only one that hurts — and it hurts a lot.
Two things to read off that chart. Training accuracy should fall as you augment; if it stays at 99.8% your augmentation is doing nothing. And the hue-shift dip is real and reproducible, because it removes the tint cue the glass–plastic decision depends on.
Mixup and CutMix
These are stranger than they look, and they work.
mixup Blend two training images pixel by pixel with weight lambda, and blend their labels by the same lambda. The model learns that a 70/30 image mix should produce a 70/30 prediction.With , image A being cardboard and image B being glass:
Trace one pixel. If A has red 200 and B has red 60 at the same position:
Check the second column: . ✓
CutMix does the same with a rectangle instead of a blend: paste a patch of B into A, and set to the fraction of area that stayed A. If the pasted patch covers 30% of the image, the label is 70% A and 30% B — the same arithmetic, but the result looks like a real image rather than a ghostly double exposure.
import numpy as np, torch
def mixup(x, y, alpha=0.4):
lam = np.random.beta(alpha, alpha)
idx = torch.randperm(x.size(0), device=x.device)
return lam * x + (1 - lam) * x[idx], y, y[idx], lam
# in the training loop
xm, ya, yb, lam = mixup(x, y)
out = model(xm)
loss = lam * criterion(out, ya) + (1 - lam) * criterion(out, yb)
| Property | Mixup | CutMix | RandomErasing |
|---|---|---|---|
| What it does | Blends two whole images | Pastes a patch of one into another | Blanks a random rectangle |
| Labels | Blended by λ | Blended by patch area | Unchanged |
| Looks realistic | No — ghostly | Yes | Yes |
| Helps most with | Overconfidence, calibration | Classification and detection | Occlusion robustness |
| Epochs needed | More — converges slower | More | Same |
| Small dataset (<2k) | Marginal | Marginal | Helps |
| Large dataset (>50k) | Clear gain | Clear gain | Modest gain |
The last two rows matter for small projects. Mixup and CutMix are strong regularisers that need extra epochs to pay off, and on a 1,200-image dataset the extra training time is usually better spent collecting more images.
Keeping the GPU fed
An augmented pipeline is CPU work: decode the JPEG, resize, transform, normalise. Do too little of it in parallel and the GPU sits waiting.
Measure the per-image CPU cost:
| # | Step | Time per image target | Runs on |
|---|---|---|---|
| 1 | JPEG decode (1500×1500) | 4.0 ms | CPU worker |
| 2 | Resize to 224 | 1.5 ms | CPU worker |
| 3 | Augment (crop, flip, jitter) | 1.6 ms | CPU worker |
| 4 | ToTensor + Normalize | 0.4 ms | CPU worker |
| 5 | **Total per image** | **7.5 ms** |
Per-image CPU cost. One worker sustains 1000/7.5 = 133 images per second.
One worker delivers 133 img/s. The GPU can consume ResNet-18 forward and backward at roughly 380 img/s. So:
| # | num_workers | Data supply (img/s) | GPU capacity (img/s) | Bottleneck | GPU utilisation target |
|---|---|---|---|---|---|
| 1 | 0 (main process) | 133 | 380 | data | 35% |
| 2 | 2 | 266 | 380 | data | 70% |
| 3 | 3 | 399 | 380 | GPU | 100% |
| 4 | 8 | 1064 | 380 | GPU | 100% |
| 5 | 16 | 1064 | 380 | GPU + memory pressure | 100%, more RAM |
Worker count versus GPU utilisation. Three workers saturate this GPU; sixteen just waste memory.
train_dl = DataLoader(
train_ds,
batch_size=32,
shuffle=True,
num_workers=8,
pin_memory=True, # faster host-to-GPU copies
persistent_workers=True, # don't respawn workers every epoch
prefetch_factor=4, # batches queued per worker
drop_last=True, # keeps batch shapes constant
)
persistent_workers=True is the cheapest win here. Without it, PyTorch tears down and restarts every worker process at the end of each epoch, which on a small dataset can cost more time than the epoch itself.
The visualization above contrasts a correct pipeline with a leaky one. The distinction it draws — fit preprocessing on training data only — is exactly the trap in the next section.
Statistics must come from training data only
If you compute your own normalization statistics instead of using ImageNet’s, compute them on the training split alone.
# Correct: training split only
mean, std, n = 0.0, 0.0, 0
for x, _ in DataLoader(train_ds_no_aug, batch_size=64):
b = x.size(0)
x = x.view(b, x.size(1), -1)
mean += x.mean(2).sum(0)
std += x.std(2).sum(0)
n += b
mean, std = mean / n, std / n
Computing them over the full dataset leaks information about the test set into training. The effect is small for normalization statistics specifically, but the habit is what matters: anything fitted to data must be fitted to training data only, and that applies to class weights, PCA, and any learned resize or crop policy too.
| # | Fitted on the full dataset | Leak severity target | Correct approach |
|---|---|---|---|
| 1 | Normalization mean/std | small but real | Fit on train split |
| 2 | Class weights | moderate | Count train split only |
| 3 | PCA or whitening | large | Fit on train, apply to all |
| 4 | Selecting which images to keep | severe | Decide before splitting |
| 5 | Choosing augmentations by test score | severe | Use validation, touch test once |
Common leaks in preprocessing, ranked
Test-time augmentation
At inference, run several augmented versions of each image and average the predictions.
@torch.no_grad()
def predict_tta(model, img):
views = [img, torch.flip(img, dims=[3])] # original + horizontal flip
probs = [torch.softmax(model(v), dim=1) for v in views]
return torch.stack(probs).mean(0)
| # | TTA setup | Accuracy target | Inference cost | Worth it? |
|---|---|---|---|---|
| 1 | Single centre crop | 89.4% | 1× | baseline |
| 2 | + horizontal flip | 90.1% | 2× | usually yes |
| 3 | + 3 scales | 90.6% | 6× | offline batch only |
| 4 | + 5 crops × 2 flips | 90.8% | 10× | competitions only |
Test-time augmentation on the recycling model. Diminishing returns arrive fast.
Flip-only TTA is the sweet spot: 0.7 points for double the cost, which is fine for a batch job and usually fine for a phone app. Beyond that you pay 5× more compute for another 0.7 points.
Only use TTA transforms that are valid for your task. If horizontal flip would change the label — reading text, arrows, or gauges — flip TTA actively hurts.
graph TD A["Raw images on disk"] --> B["Split by session<br/>train / val / test"] B --> C["Compute stats on TRAIN only"] C --> D["Train: random augmentation"] C --> E["Val/Test: deterministic resize + crop"] D --> F["DataLoader<br/>workers tuned to saturate GPU"] E --> G["DataLoader<br/>shuffle=False"] F --> H["Training"] G --> H H --> I["Optional TTA at inference"]
- You have more than about 10,000 images
- You can afford 2–3x the training epochs
- The train-validation gap is still large after basic augmentation
- Calibrated probabilities matter, not just the argmax
- You have fewer than about 2,000 images — collect more instead
- Basic crop and flip already closed the gap
- Your training budget is tight
- The transforms would change the correct label
Practice task
Use the classifier from the previous post, or any small dataset.
- Train with no augmentation. Record train and validation accuracy and the gap.
- Add
RandomResizedCrop(224, scale=(0.7, 1.0)). Record the gap. - Add horizontal flip. Then colour jitter. Then rotation. One at a time.
- Plot train and validation accuracy across all five runs.
- Now set
scale=(0.08, 1.0)and save 20 augmented images to disk. Look at them. How many contain no object? - Time one epoch at
num_workersof 0, 2, 4, 8, and 16. Plot epoch time against workers. - Add flip TTA at inference and measure the accuracy change.
- Add an augmentation you believe is wrong for your task — hue shift, or vertical flip if orientation matters. Measure how much it costs.
Step 5 is the one that changes behaviour permanently. Seeing your own training images cropped down to blank background, still carrying a confident class label, makes the scale parameter stop being an abstraction.
Summary
Every augmentation asserts that a change does not alter the label. Horizontal flip and mild crops are usually safe; hue shift cost 2.3 points on the recycling dataset because material tint was one of the few real cues separating glass from plastic.
RandomResizedCrop’s default scale=(0.08, 1.0) keeps as little as 8% of the area — about 141×141 pixels from a 500×500 photo — often producing a blank crop that still carries a class label. Use (0.5, 1.0) or (0.7, 1.0) on small datasets.
Add augmentations one at a time and watch the train–validation gap, which fell from 21.5 to 2.5 points here. Training accuracy falling is the intended effect, not a problem.
Mixup blends images and labels by the same : at , pixel values 200 and 60 become 158, and the label becomes . Both mixup and CutMix need larger datasets and more epochs to pay off.
Finally, size the DataLoader from measurement. At 7.5 ms of CPU work per image, one worker supplies 133 img/s against a GPU that consumes 380, so three workers saturate it and sixteen only cost RAM.
- Every augmentation is a claim that the label shouldn't change. Check the claim against your task.
- RandomResizedCrop's default scale=(0.08, 1.0) can crop the object out entirely — use (0.5, 1.0) on small data.
- Add augmentations one at a time and measure; a bundle can hide a transform that actively hurts.
- Training accuracy should fall as you augment. If it doesn't, the augmentation is doing nothing.
- Hue shift destroys colour cues. Rotation destroys orientation cues. Neither raises an error.
- Mixup blends pixels and labels by the same λ; CutMix uses patch area as λ.
- Mixup and CutMix need more than about 10k images and extra epochs to be worth it.
- Fit normalization statistics on the training split only — and the same rule covers class weights and PCA.
- Augment train only. Validation and test must be deterministic or your metrics become noise.
- Size num_workers by measurement: enough to saturate the GPU, and not one more.
What comes next
You have now trained and regularised a model while treating ResNet-18 as a black box. CNN architectures from LeNet to ResNet opens it up: what a convolution layer actually computes, why depth stalled at around 20 layers before residual connections, and how to read an architecture diagram well enough to choose between backbones on purpose.