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