| """ |
| data/preprocessing.py |
| --------------------- |
| Image preprocessing pipeline for NIH ChestX-ray14. |
| |
| Strategy: |
| 1. CLAHE (Contrast Limited Adaptive Histogram Equalization) β boosts subtle |
| lung texture visibility in low-contrast X-rays. |
| 2. Albumentations augmentation pipeline β radiologically realistic transforms. |
| 3. ViT normalization with ImageNet-21k stats (the pre-training distribution). |
| |
| CLAHE rationale: |
| Chest X-rays have a very narrow dynamic range. CLAHE locally equalizes |
| contrast in small tile windows, revealing subtle nodules and infiltrates |
| that global equalization would miss. |
| """ |
|
|
| import cv2 |
| import numpy as np |
| import albumentations as A |
| from albumentations.pytorch import ToTensorV2 |
| from PIL import Image |
| from typing import Tuple |
|
|
|
|
| |
| IMAGENET_MEAN = (0.5, 0.5, 0.5) |
| IMAGENET_STD = (0.5, 0.5, 0.5) |
| |
|
|
|
|
| def apply_clahe(image_gray: np.ndarray, clip_limit: float = 2.0, tile_size: int = 8) -> np.ndarray: |
| """ |
| Apply CLAHE to a grayscale X-ray image. |
| |
| Args: |
| image_gray: Single-channel uint8 grayscale image (H, W). |
| clip_limit: Threshold for contrast limiting. Higher = more contrast boost. |
| tile_size: Grid size for local histogram equalization. |
| |
| Returns: |
| CLAHE-enhanced grayscale image (H, W) as uint8. |
| """ |
| clahe = cv2.createCLAHE( |
| clipLimit=clip_limit, |
| tileGridSize=(tile_size, tile_size), |
| ) |
| return clahe.apply(image_gray) |
|
|
|
|
| def load_and_preprocess_raw( |
| image_path: str, |
| output_size: Tuple[int, int] = (224, 224), |
| clip_limit: float = 2.0, |
| tile_size: int = 8, |
| ) -> np.ndarray: |
| """ |
| Load a chest X-ray PNG, apply CLAHE, and convert to 3-channel RGB. |
| |
| The NIH images are grayscale PNGs saved as 8-bit or 16-bit. |
| We: |
| 1. Read as grayscale |
| 2. Normalize 16-bit β 8-bit if needed |
| 3. Apply CLAHE |
| 4. Resize to output_size |
| 5. Convert to 3-channel (replicate gray β R, G, B) |
| |
| Args: |
| image_path: Path to the .png X-ray file. |
| output_size: (width, height) tuple for resizing. |
| clip_limit: CLAHE clip limit. |
| tile_size: CLAHE tile grid size. |
| |
| Returns: |
| np.ndarray of shape (H, W, 3) uint8 β ready for Albumentations. |
| """ |
| |
| img = cv2.imread(str(image_path), cv2.IMREAD_GRAYSCALE) |
| if img is None: |
| raise FileNotFoundError(f"Cannot read image: {image_path}") |
|
|
| |
| if img.dtype == np.uint16: |
| img = (img / 256).astype(np.uint8) |
|
|
| |
| img = apply_clahe(img, clip_limit=clip_limit, tile_size=tile_size) |
|
|
| |
| img = cv2.resize(img, output_size, interpolation=cv2.INTER_AREA) |
|
|
| |
| img = cv2.cvtColor(img, cv2.COLOR_GRAY2RGB) |
|
|
| return img |
|
|
|
|
| |
|
|
| def get_train_transforms(image_size: int = 224) -> A.Compose: |
| """ |
| Training augmentation pipeline. |
| |
| Augmentations chosen for radiological realism: |
| - HorizontalFlip: Patients can be imaged in either orientation |
| - ShiftScaleRotate: Minor positioning variance (Β±10Β°, scale Β±10%) |
| - RandomBrightnessContrast: Exposure variation between X-ray machines |
| - GridDistortion: Simulates slight body movement artifacts |
| - GaussNoise: Detector noise |
| |
| No vertical flip β lungs have a fixed anatomical orientation. |
| No heavy color jitter β X-rays are monochrome. |
| """ |
| return A.Compose([ |
| A.HorizontalFlip(p=0.5), |
| A.Affine( |
| translate_percent={"x": (-0.05, 0.05), "y": (-0.05, 0.05)}, |
| scale=(0.90, 1.10), |
| rotate=(-10, 10), |
| p=0.5, |
| ), |
| A.RandomBrightnessContrast( |
| brightness_limit=0.15, |
| contrast_limit=0.15, |
| p=0.4, |
| ), |
| A.GridDistortion( |
| num_steps=5, |
| distort_limit=0.05, |
| p=0.2, |
| ), |
| A.GaussNoise(std_range=(0.02, 0.10), p=0.2), |
| |
| A.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD), |
| ToTensorV2(), |
| ]) |
|
|
|
|
| def get_val_transforms(image_size: int = 224) -> A.Compose: |
| """ |
| Validation / inference transforms β no augmentation, only normalize + tensorize. |
| """ |
| return A.Compose([ |
| A.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD), |
| ToTensorV2(), |
| ]) |
|
|
|
|
| def denormalize(tensor_image) -> np.ndarray: |
| """ |
| Reverse ImageNet normalization for visualization. |
| |
| Args: |
| tensor_image: torch.Tensor (C, H, W) in normalized space. |
| |
| Returns: |
| np.ndarray (H, W, 3) uint8 for display. |
| """ |
| import torch |
| mean = torch.tensor(IMAGENET_MEAN).view(3, 1, 1) |
| std = torch.tensor(IMAGENET_STD).view(3, 1, 1) |
| img = tensor_image.cpu().float() * std + mean |
| img = img.permute(1, 2, 0).numpy() |
| img = np.clip(img * 255, 0, 255).astype(np.uint8) |
| return img |
|
|