| """ |
| utils/mammogram_preprocessing.py |
| ββββββββββββββββββββββββββββββββββ |
| Preprocessing pipeline for full-field digital mammography (FFDM). |
| |
| Handles both DICOM files and standard image formats (PNG, JPG). |
| Applies mammogram-specific preprocessing: |
| - VOI LUT windowing for DICOM files |
| - Breast region normalisation |
| - Grayscale to 3-channel RGB conversion |
| - Mammogram-appropriate augmentations (no stain jitter) |
| |
| Install |
| βββββββ |
| pip install pydicom pylibjpeg python-gdcm |
| """ |
|
|
| from __future__ import annotations |
|
|
| from pathlib import Path |
| from typing import Optional, Union |
|
|
| import numpy as np |
| from PIL import Image |
| from torchvision import transforms |
|
|
| |
| |
| try: |
| import pydicom |
| from pydicom.pixel_data_handlers.util import apply_voi_lut |
| PYDICOM_AVAILABLE = True |
| except ImportError: |
| PYDICOM_AVAILABLE = False |
|
|
| |
| MAMMOGRAM_SIZE = 512 |
| IMAGENET_MEAN = [0.485, 0.456, 0.406] |
| IMAGENET_STD = [0.229, 0.224, 0.225] |
|
|
|
|
| |
|
|
| def load_dicom(path: Union[str, Path]) -> Image.Image: |
| """ |
| Load a DICOM mammogram file and convert to an 8-bit RGB PIL Image. |
| |
| Applies VOI LUT windowing if available in the DICOM metadata, |
| otherwise falls back to min-max normalisation. |
| |
| MONOCHROME1 images (where high pixel = dark) are inverted so |
| the tissue appears bright on a dark background, matching the |
| visual convention used during model training. |
| |
| Parameters |
| ---------- |
| path : str | Path |
| Path to a .dcm DICOM file. |
| |
| Returns |
| ------- |
| PIL.Image.Image β RGB image ready for preprocessing. |
| |
| Raises |
| ------ |
| ImportError β if pydicom is not installed. |
| FileNotFoundError β if the file does not exist. |
| """ |
| if not PYDICOM_AVAILABLE: |
| raise ImportError( |
| "pydicom not installed. Run: pip install pydicom pylibjpeg python-gdcm" |
| ) |
|
|
| path = Path(path) |
| if not path.exists(): |
| raise FileNotFoundError(f"DICOM file not found: {path}") |
|
|
| dcm = pydicom.dcmread(str(path)) |
|
|
| |
| try: |
| pixel_array = apply_voi_lut(dcm.pixel_array, dcm) |
| except Exception: |
| pixel_array = dcm.pixel_array.astype(np.float32) |
|
|
| pixel_array = pixel_array.astype(np.float32) |
|
|
| |
| p_min, p_max = pixel_array.min(), pixel_array.max() |
| if p_max > p_min: |
| pixel_array = (pixel_array - p_min) / (p_max - p_min) * 255.0 |
| else: |
| pixel_array = np.zeros_like(pixel_array) |
|
|
| pixel_array = pixel_array.astype(np.uint8) |
|
|
| |
| if hasattr(dcm, "PhotometricInterpretation"): |
| if dcm.PhotometricInterpretation == "MONOCHROME1": |
| pixel_array = 255 - pixel_array |
|
|
| |
| if pixel_array.ndim == 2: |
| rgb = np.stack([pixel_array, pixel_array, pixel_array], axis=-1) |
| else: |
| rgb = pixel_array |
|
|
| return Image.fromarray(rgb, mode="RGB") |
|
|
|
|
| def load_mammogram(path: Union[str, Path]) -> Image.Image: |
| """ |
| Load a mammogram from either DICOM or standard image format. |
| |
| Automatically detects format by file extension. |
| |
| Parameters |
| ---------- |
| path : str | Path |
| Path to DICOM (.dcm) or image file (.png, .jpg, .tiff). |
| |
| Returns |
| ------- |
| PIL.Image.Image β RGB image. |
| """ |
| path = Path(path) |
| if path.suffix.lower() in {".dcm", ".dicom"}: |
| return load_dicom(path) |
|
|
| |
| img = Image.open(path).convert("RGB") |
|
|
| |
| |
| if img.mode == "L": |
| arr = np.array(img) |
| rgb = np.stack([arr, arr, arr], axis=-1) |
| return Image.fromarray(rgb, mode="RGB") |
|
|
| return img |
|
|
|
|
| |
|
|
| class BreastRegionEnhancer: |
| """ |
| Enhances breast tissue contrast using adaptive histogram equalisation. |
| |
| Applied per-channel to improve visibility of masses and calcifications |
| without affecting the overall image structure. |
| |
| Parameters |
| ---------- |
| clip_limit : float |
| CLAHE clip limit. Higher = more aggressive enhancement. |
| p : float |
| Probability of applying this transform. |
| """ |
|
|
| def __init__(self, clip_limit: float = 2.0, p: float = 0.5) -> None: |
| self.clip_limit = clip_limit |
| self.p = p |
|
|
| def __call__(self, img: Image.Image) -> Image.Image: |
| if np.random.random() > self.p: |
| return img |
|
|
| try: |
| import cv2 |
| arr = np.array(img) |
| clahe = cv2.createCLAHE( |
| clipLimit = self.clip_limit, |
| tileGridSize = (8, 8), |
| ) |
| |
| enhanced = np.stack( |
| [clahe.apply(arr[:, :, c]) for c in range(arr.shape[2])], |
| axis=-1, |
| ) |
| return Image.fromarray(enhanced.astype(np.uint8), mode="RGB") |
| except ImportError: |
| return img |
|
|
|
|
| class RandomElasticDeformation: |
| """ |
| Random elastic deformation β simulates tissue compression variation |
| from different mammography unit pressures. |
| |
| Parameters |
| ---------- |
| alpha : float |
| Strength of deformation. |
| sigma : float |
| Smoothness of deformation field. |
| p : float |
| Probability of applying. |
| """ |
|
|
| def __init__( |
| self, |
| alpha: float = 34.0, |
| sigma: float = 4.0, |
| p: float = 0.3, |
| ) -> None: |
| self.alpha = alpha |
| self.sigma = sigma |
| self.p = p |
|
|
| def __call__(self, img: Image.Image) -> Image.Image: |
| if np.random.random() > self.p: |
| return img |
|
|
| try: |
| from scipy.ndimage import gaussian_filter, map_coordinates |
|
|
| arr = np.array(img, dtype=np.float32) |
| h, w = arr.shape[:2] |
| dx = gaussian_filter( |
| (np.random.rand(h, w) * 2 - 1) * self.alpha, self.sigma |
| ) |
| dy = gaussian_filter( |
| (np.random.rand(h, w) * 2 - 1) * self.alpha, self.sigma |
| ) |
| x, y = np.meshgrid(np.arange(w), np.arange(h)) |
| coords = [ |
| np.clip(y + dy, 0, h - 1).ravel(), |
| np.clip(x + dx, 0, w - 1).ravel(), |
| ] |
|
|
| result = np.stack([ |
| map_coordinates(arr[:, :, c], coords, order=1).reshape(h, w) |
| for c in range(arr.shape[2]) |
| ], axis=-1) |
|
|
| return Image.fromarray(result.clip(0, 255).astype(np.uint8), mode="RGB") |
| except ImportError: |
| return img |
|
|
|
|
| def build_mammogram_train_transform() -> transforms.Compose: |
| """ |
| Training augmentation pipeline for mammograms. |
| |
| Mammogram-appropriate augmentations β NO stain jitter (mammograms |
| are X-ray images, not H&E-stained tissue). Augmentations simulate: |
| - Different patient positioning (flips, rotation) |
| - Tissue compression variation (elastic deformation) |
| - Scanner variation (brightness/contrast) |
| - Tissue contrast differences (CLAHE) |
| """ |
| return transforms.Compose([ |
| BreastRegionEnhancer(clip_limit=2.0, p=0.4), |
| RandomElasticDeformation(alpha=34.0, sigma=4.0, p=0.3), |
| transforms.Resize((MAMMOGRAM_SIZE, MAMMOGRAM_SIZE)), |
| transforms.RandomHorizontalFlip(p=0.5), |
| transforms.RandomVerticalFlip(p=0.2), |
| transforms.RandomRotation(degrees=10), |
| transforms.ColorJitter( |
| brightness = 0.15, |
| contrast = 0.15, |
| ), |
| transforms.RandomAffine( |
| degrees = 0, |
| translate = (0.05, 0.05), |
| scale = (0.95, 1.05), |
| ), |
| transforms.ToTensor(), |
| transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD), |
| ]) |
|
|
|
|
| def build_mammogram_inference_transform() -> transforms.Compose: |
| """ |
| Inference transform β resize and normalise only. No augmentation.""" |
| return transforms.Compose([ |
| transforms.Resize((MAMMOGRAM_SIZE, MAMMOGRAM_SIZE)), |
| transforms.ToTensor(), |
| transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD), |
| ]) |
|
|