Spaces:
Sleeping
Sleeping
| """ | |
| 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] | |