File size: 11,442 Bytes
194eedd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
"""
data/transforms.py

Augmentation pipelines for the OCT hierarchical classification pipeline.

Resolution Strategy (from architectural directives):
  Level 1 (Gatekeeper):   224Γ—224 β€” maximum throughput for binary screening.
  Level 2 (Router):       224Γ—224 β€” consistent feature space with L1.
  Level 3 (Specialists):  384Γ—384 β€” fine-grained structural detail for
                           CNV vs DRUSEN, RAO vs RVO, etc.

Pipeline Variants:
  - Standard Train:  Random crop/flip/rotation + ColorJitter + GaussianBlur +
                     RandomErasing. Used for L1, L2, L3_Macular, L3_Diabetic.
  - Heavy Train:     Adds RandomAffine + stronger erasing. Used for
                     extreme minority L3 specialists (Vascular, Fluid, Structural)
                     where RAO has only 22 samples and CSR has 102.
  - Val/Test:        Deterministic resize + CenterCrop + normalize only.

All pipelines use ImageNet mean/std for pretrained backbone compatibility.
"""

import numpy as np
from torchvision import transforms
import cv2

# ── ImageNet statistics ───────────────────────────────────────────────────────
IMAGENET_MEAN = [0.485, 0.456, 0.406]
IMAGENET_STD  = [0.229, 0.224, 0.225]

# ── Resolution constants ──────────────────────────────────────────────────────
RES_L1_L2: int = 224   # Level 1 & 2 input resolution
RES_L3:    int = 384   # Level 3 specialist input resolution

# Intermediate crop sizes (resize target before random/center crop)
_CROP_L1_L2: int = 256
_CROP_L3:    int = 416


# ──────────────────────────────────────────────────────────────────────────────
# CLAHE Preprocessing
# ──────────────────────────────────────────────────────────────────────────────

class CLAHETransform:
    """
    Contrast Limited Adaptive Histogram Equalization for OCT images.

    Applied per-image BEFORE resize/crop to normalise brightness and local
    contrast variation across different OCT scanner manufacturers
    (Zeiss, Heidelberg, Topcon, etc.).

    Without this step, the model may learn scanner-specific intensity
    distributions rather than pathology β€” a form of shortcut learning that
    degrades performance on unseen devices.

    Applied identically at train, val, and test time β€” this is NOT an
    augmentation, it is a deterministic preprocessing step.

    Args:
        clip_limit:  Contrast clip threshold. 2.0 is standard for OCT.
                     Higher values = more contrast, more noise amplification.
        tile_grid:   Size of the adaptive tile grid. (8, 8) is standard.
    """

    def __init__(
        self,
        clip_limit: float = 2.0,
        tile_grid: tuple = (8, 8),
    ) -> None:
        self.clip_limit = clip_limit
        self.tile_grid = tile_grid
        self._clahe = None

    def __call__(self, img) -> "PIL.Image.Image":
        from PIL import Image as PILImage
        if self._clahe is None:
            self._clahe = cv2.createCLAHE(
                clipLimit=self.clip_limit,
                tileGridSize=self.tile_grid,
            )
        # Convert to numpy grayscale β€” OCT images carry most diagnostic
        # information in luminance; colour channels are usually redundant
        img_np = np.array(img.convert("L"), dtype=np.uint8)
        equalized = self._clahe.apply(img_np)
        # Stack to 3-channel RGB β€” required for ImageNet-pretrained backbones
        rgb = np.stack([equalized, equalized, equalized], axis=-1)
        return PILImage.fromarray(rgb, mode="RGB")


# Shared instance used in all transform pipelines
_CLAHE = CLAHETransform(clip_limit=2.0, tile_grid=(8, 8))


# ──────────────────────────────────────────────────────────────────────────────
# Transform factory functions
# ──────────────────────────────────────────────────────────────────────────────

def get_train_transforms(resolution: int = RES_L1_L2) -> transforms.Compose:
    """
    Standard training augmentation pipeline.

    Designed to:
      - Increase geometric diversity (flip, rotate, crop).
      - Simulate OCT scan artefacts (GaussianBlur, ColorJitter).
      - Force the network to ignore local texture via RandomErasing.

    Args:
        resolution: Target output resolution (224 or 384).

    Returns:
        Composed torchvision transform.
    """
    crop_size = _CROP_L3 if resolution == RES_L3 else _CROP_L1_L2
    return transforms.Compose([
        _CLAHE,                          # Scanner normalisation (deterministic)
        transforms.Resize(
            crop_size,
            interpolation=transforms.InterpolationMode.BICUBIC,
        ),
        transforms.RandomCrop(resolution),
        transforms.RandomHorizontalFlip(p=0.5),
        transforms.RandomVerticalFlip(p=0.2),
        transforms.RandomRotation(degrees=15),
        transforms.ColorJitter(
            brightness=0.3,
            contrast=0.3,
            saturation=0.1,
            hue=0.05,
        ),
        transforms.RandomApply(
            [transforms.GaussianBlur(kernel_size=5, sigma=(0.1, 2.0))],
            p=0.3,
        ),
        transforms.ToTensor(),
        # RandomErasing after ToTensor (operates on tensor, not PIL image)
        transforms.RandomErasing(
            p=0.2,
            scale=(0.02, 0.10),
            ratio=(0.3, 3.3),
            value="random",
        ),
        transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
    ])


def get_heavy_train_transforms(resolution: int = RES_L3) -> transforms.Compose:
    """
    Heavy augmentation pipeline for extreme minority classes.

    Applied to L3_Vascular (RAO=22, RVO=101, MH=102), L3_Fluid (CSR=102),
    and L3_Structural (ERM=155, VID=76) to maximise synthetic variation.

    Adds on top of the standard pipeline:
      - RandomAffine (translate, scale, shear)
      - Stronger rotation (Β±30Β°)
      - Stronger RandomErasing scale

    Args:
        resolution: Target output resolution (typically 384 for L3).
    """
    crop_size = _CROP_L3 if resolution == RES_L3 else _CROP_L1_L2
    return transforms.Compose([
        _CLAHE,                          # Scanner normalisation (deterministic)
        transforms.Resize(
            crop_size,
            interpolation=transforms.InterpolationMode.BICUBIC,
        ),
        transforms.RandomCrop(resolution),
        transforms.RandomHorizontalFlip(p=0.5),
        transforms.RandomVerticalFlip(p=0.5),
        transforms.RandomRotation(degrees=30),
        transforms.RandomAffine(
            degrees=20,
            translate=(0.10, 0.10),
            scale=(0.85, 1.15),
            shear=10,
            interpolation=transforms.InterpolationMode.BICUBIC,
        ),
        transforms.ColorJitter(
            brightness=0.4,
            contrast=0.4,
            saturation=0.2,
            hue=0.10,
        ),
        transforms.RandomApply(
            [transforms.GaussianBlur(kernel_size=5, sigma=(0.1, 3.0))],
            p=0.4,
        ),
        transforms.ToTensor(),
        transforms.RandomErasing(
            p=0.35,
            scale=(0.02, 0.15),
            ratio=(0.3, 3.3),
            value="random",
        ),
        transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
    ])


def get_val_transforms(resolution: int = RES_L1_L2) -> transforms.Compose:
    """
    Deterministic validation/test pipeline (no augmentation).

    Args:
        resolution: Target output resolution (224 or 384).

    Returns:
        Composed torchvision transform.
    """
    crop_size = _CROP_L3 if resolution == RES_L3 else _CROP_L1_L2
    return transforms.Compose([
        _CLAHE,                          # Scanner normalisation β€” must match train pipeline
        transforms.Resize(
            crop_size,
            interpolation=transforms.InterpolationMode.BICUBIC,
        ),
        transforms.CenterCrop(resolution),
        transforms.ToTensor(),
        transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
    ])


# ──────────────────────────────────────────────────────────────────────────────
# Registry β€” keyed by (mode, split)
# ──────────────────────────────────────────────────────────────────────────────

#: Complete transform registry. Access via :func:`get_transforms`.
TRANSFORM_REGISTRY: dict = {
    # Level 1 β€” 224px, standard augmentation
    "level1": {
        "train": get_train_transforms(RES_L1_L2),
        "val":   get_val_transforms(RES_L1_L2),
    },
    # Level 2 β€” 224px, HEAVY augmentation (minority class collapse prevention)
    "level2": {
        "train": get_heavy_train_transforms(RES_L1_L2),
        "val":   get_val_transforms(RES_L1_L2),
    },
    # Level 3 Macular β€” 384px, standard (large enough dataset)
    "level3_macular": {
        "train": get_train_transforms(RES_L3),
        "val":   get_val_transforms(RES_L3),
    },
    # Level 3 Diabetic β€” 384px, standard (DME=11,495 samples)
    "level3_diabetic": {
        "train": get_train_transforms(RES_L3),
        "val":   get_val_transforms(RES_L3),
    },
    # Level 3 Vascular β€” 384px, HEAVY (MH=102, RVO=101, RAO=22)
    "level3_vascular": {
        "train": get_heavy_train_transforms(RES_L3),
        "val":   get_val_transforms(RES_L3),
    },
    # Level 3 Fluid β€” 384px, HEAVY (CSR=102 only)
    "level3_fluid": {
        "train": get_heavy_train_transforms(RES_L3),
        "val":   get_val_transforms(RES_L3),
    },
    # Level 3 Structural β€” 384px, HEAVY (ERM=155, VID=76)
    "level3_structural": {
        "train": get_heavy_train_transforms(RES_L3),
        "val":   get_val_transforms(RES_L3),
    },
}


def get_transforms(mode: str, split: str = "train") -> transforms.Compose:
    """
    Convenience accessor for the transform registry.

    Args:
        mode:  Dataset mode (e.g., ``'level1'``, ``'level3_vascular'``).
        split: ``'train'`` or ``'val'``.

    Returns:
        A ``torchvision.transforms.Compose`` instance.

    Raises:
        ValueError: If mode or split is invalid.
    """
    if mode not in TRANSFORM_REGISTRY:
        raise ValueError(
            f"Unknown mode: '{mode}'. "
            f"Choose from: {sorted(TRANSFORM_REGISTRY.keys())}"
        )
    if split not in ("train", "val"):
        raise ValueError(f"Unknown split: '{split}'. Use 'train' or 'val'.")
    return TRANSFORM_REGISTRY[mode][split]