File size: 5,464 Bytes
37fbec9 | 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 | """
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-21k normalization (matches ViT pre-training) βββββββββββββββββββββ
IMAGENET_MEAN = (0.5, 0.5, 0.5)
IMAGENET_STD = (0.5, 0.5, 0.5)
# Note: google/vit-base-patch16-224-in21k uses [-1, 1] normalization (mean=0.5, std=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.
"""
# Read as grayscale (handles both 8-bit and 16-bit)
img = cv2.imread(str(image_path), cv2.IMREAD_GRAYSCALE)
if img is None:
raise FileNotFoundError(f"Cannot read image: {image_path}")
# Normalize 16-bit to 8-bit
if img.dtype == np.uint16:
img = (img / 256).astype(np.uint8)
# Apply CLAHE
img = apply_clahe(img, clip_limit=clip_limit, tile_size=tile_size)
# Resize
img = cv2.resize(img, output_size, interpolation=cv2.INTER_AREA)
# Convert grayscale β 3-channel RGB (ViT expects 3 channels)
img = cv2.cvtColor(img, cv2.COLOR_GRAY2RGB)
return img # shape: (H, W, 3), dtype: uint8
# ββ Albumentations Pipelines βββββββββββββββββββββββββββββββββββββββββββββββββ
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),
# Normalize using ImageNet-21k stats
A.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
ToTensorV2(), # (H, W, C) β (C, H, W), float32
])
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 # denormalize
img = img.permute(1, 2, 0).numpy() # (C, H, W) β (H, W, C)
img = np.clip(img * 255, 0, 255).astype(np.uint8)
return img
|