smartvision-ai / src /data /augmentor.py
Priya Monisha
fix: pass ruff lint+format, clean README, remove stray script
fa0ce5b
Raw History Blame Contribute Delete
5.56 kB
# src/data/augmentor.py
# Augmentation pipelines for SmartVision CNN training.
#
# Pipeline design principles:
#
# PIL-space first (indices 0-4, before ToImage at index 5):
# Geometric/color transforms on PIL input. PIL handles uint8 arithmetic
# and boundary conditions correctly. Cost is CPU-bound but negligible
# at 224x224 images.
#
# Float tensor space (indices 7-9, after ToDtype at index 6):
# RandomZoomOut requires float32 input with fill values in [0.0, 1.0].
# fill=IMAGENET_MEAN: after Normalize, padded pixels become exactly 0.0.
# Proof: (IMAGENET_MEAN - IMAGENET_MEAN) / IMAGENET_STD = [0, 0, 0]
# fill=0.0: (0 - mean) / std = [-2.12, -2.04, -1.80] after Normalize (wrong)
# fill=0.5: gives [+0.065, +0.196, +0.418] per channel (wrong, colored border)
#
# RandomPerspective border artifact (accepted tradeoff):
# PIL RandomPerspective default fill=0 -> black border in uint8.
# After full pipeline: (0/255 - mean) / std ~= [-2.12, -2.04, -1.80].
# At distortion_scale=0.2, p=0.3: border <5% of pixels on 30% of samples.
# Net gradient impact is small. Mitigation if needed: move after ToDtype
# and set fill=IMAGENET_MEAN.
#
# Why RandomGrayscale(p=0.05):
# COCO household items (cup/bowl/bottle) are confused via memorized color
# patterns from specific training images. 5% grayscale forces color-agnostic
# feature learning occasionally without degrading vehicle/animal color signal.
#
# Why RandomPerspective (not RandomErasing):
# COCO ground-truth boxes reflect real camera perspective variation.
# Perspective augmentation models this directly.
# RandomErasing destroys local texture patches -- the primary discriminator
# for fine-grained categories. Do not add it.
#
# Round 3 changes from Round 2:
# RandomRotation: 15 -> 20 degrees
# ColorJitter brightness/contrast: 0.2 -> 0.3
# ColorJitter saturation: 0.1 -> 0.2
# RandomGrayscale(p=0.05): ADDED (PIL-space)
# RandomPerspective(distortion_scale=0.2, p=0.3): ADDED (PIL-space)
# RandomZoomOut side_range: (1.0, 1.3) -> (1.0, 1.4)
import torch
import torchvision.transforms.v2 as T
IMAGENET_MEAN: list[float] = [0.485, 0.456, 0.406]
IMAGENET_STD: list[float] = [0.229, 0.224, 0.225]
def get_train_transforms(image_size: int = 224) -> T.Compose:
"""
Training augmentation pipeline -- 10 transforms.
Ordered execution (order is load-bearing):
[0] RandomHorizontalFlip(p=0.5) PIL geometric
[1] RandomRotation(degrees=20) PIL geometric
[2] ColorJitter(0.3, 0.3, 0.2, 0.05) PIL color
[3] RandomGrayscale(p=0.05) PIL color NEW Round 3
[4] RandomPerspective(scale=0.2, p=0.3) PIL geometric NEW Round 3
[5] ToImage() PIL -> uint8 CHW tensor
[6] ToDtype(torch.float32, scale=True) uint8 -> float32 [0, 1]
[7] RandomZoomOut(fill=IMAGENET_MEAN, ...) float32 tensor
[8] Resize((image_size, image_size))
[9] Normalize(IMAGENET_MEAN, IMAGENET_STD)
Hard ordering constraints:
[3], [4] < [5]: PIL ops must run before ToImage conversion
[6] < [7]: ZoomOut fill is float32 [0, 1] space
[9] = last: Normalize must be final transform
"""
return T.Compose(
[
# PIL-space geometric
T.RandomHorizontalFlip(p=0.5),
T.RandomRotation(degrees=20),
# PIL-space color
T.ColorJitter(
brightness=0.3,
contrast=0.3,
saturation=0.2,
hue=0.05,
),
T.RandomGrayscale(p=0.05),
# PIL-space geometric (perspective)
# fill=0 default: black border in uint8 -> ~[-2.12,-2.04,-1.80] after Normalize.
# Accepted: borders are small (<5% of pixels) and infrequent (p=0.3).
T.RandomPerspective(distortion_scale=0.2, p=0.3),
# Convert to float32 tensor
T.ToImage(),
T.ToDtype(torch.float32, scale=True),
# Float32 tensor augmentation
# fill=IMAGENET_MEAN: padded pixels normalize to 0.0 per channel.
T.RandomZoomOut(
fill=IMAGENET_MEAN,
side_range=(1.0, 1.4),
p=0.3,
),
# Finalize (single resize after all spatial augmentation)
T.Resize((image_size, image_size), antialias=True),
# Normalize last
T.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
]
)
def get_eval_transforms(image_size: int = 224) -> T.Compose:
"""
Evaluation/test pipeline -- fully deterministic.
Matches inference-time preprocessing exactly.
Contains zero stochastic transforms.
"""
return T.Compose(
[
T.ToImage(),
T.ToDtype(torch.float32, scale=True),
T.Resize((image_size, image_size), antialias=True),
T.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
]
)
def denormalize(tensor: torch.Tensor) -> "np.ndarray": # noqa: F821
"""Reverse ImageNet normalization. Returns HWC uint8 numpy array [0, 255]."""
import numpy as np
mean = torch.tensor(IMAGENET_MEAN, dtype=torch.float32).view(3, 1, 1)
std = torch.tensor(IMAGENET_STD, dtype=torch.float32).view(3, 1, 1)
img = tensor.clone().cpu() * std + mean
return (img.clamp(0.0, 1.0).permute(1, 2, 0).numpy() * 255).astype(np.uint8)