File size: 5,560 Bytes
58027ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ea8a2d
58027ad
 
2ea8a2d
58027ad
fa0ce5b
2ea8a2d
 
58027ad
2ea8a2d
58027ad
 
 
 
 
 
 
 
 
 
 
 
 
2ea8a2d
58027ad
 
 
 
 
fa0ce5b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ea8a2d
 
58027ad
 
 
 
 
 
fa0ce5b
 
 
 
 
 
 
 
2ea8a2d
 
fa0ce5b
58027ad
2ea8a2d
fa0ce5b
58027ad
fa0ce5b
 
58027ad
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# 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)