medai / utils /mammogram_preprocessing.py
Relixsx
Deploy MedAI backend to Hugging Face Space
3a8534b
Raw
History Blame Contribute Delete
9.07 kB
"""
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
# DICOM support β€” optional import so the rest of the codebase doesn't break
# if pydicom is not installed
try:
import pydicom
from pydicom.pixel_data_handlers.util import apply_voi_lut
PYDICOM_AVAILABLE = True
except ImportError:
PYDICOM_AVAILABLE = False
# ── Constants ──────────────────────────────────────────────────────────────────
MAMMOGRAM_SIZE = 512 # EfficientNet-B4 input resolution
IMAGENET_MEAN = [0.485, 0.456, 0.406]
IMAGENET_STD = [0.229, 0.224, 0.225]
# ── DICOM loader ───────────────────────────────────────────────────────────────
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))
# Apply VOI LUT windowing (converts to display-ready values)
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)
# Normalise to [0, 255]
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)
# MONOCHROME1: invert so tissue is bright
if hasattr(dcm, "PhotometricInterpretation"):
if dcm.PhotometricInterpretation == "MONOCHROME1":
pixel_array = 255 - pixel_array
# Convert grayscale β†’ RGB (EfficientNet expects 3 channels)
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)
# Standard image format
img = Image.open(path).convert("RGB")
# If grayscale was saved as single-channel PNG, already converted above.
# But if it's a true grayscale mammogram saved as PNG:
if img.mode == "L":
arr = np.array(img)
rgb = np.stack([arr, arr, arr], axis=-1)
return Image.fromarray(rgb, mode="RGB")
return img
# ── Mammogram-specific augmentation transforms ──────────────────────────────────
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),
)
# Apply CLAHE to each channel
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 # OpenCV not available β€” skip silently
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 # scipy not available β€” skip
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),
])