| """ |
| utils/preprocessing.py |
| βββββββββββββββββββββββ |
| Image preprocessing pipeline that converts a raw histopathology image |
| (file path, PIL Image, or numpy array) into a normalised tensor of |
| shape (1, 3, 224, 224) ready for model inference. |
| |
| Normalization follows ImageNet standards as required by the spec: |
| Mean : [0.485, 0.456, 0.406] |
| Std : [0.229, 0.224, 0.225] |
| """ |
|
|
| from __future__ import annotations |
|
|
| from pathlib import Path |
| from typing import Union |
|
|
| import numpy as np |
| import torch |
| from PIL import Image |
| from torchvision import transforms |
|
|
| |
| IMAGENET_MEAN = [0.485, 0.456, 0.406] |
| IMAGENET_STD = [0.229, 0.224, 0.225] |
|
|
| |
| TARGET_SIZE = (224, 224) |
|
|
|
|
| |
| def build_inference_transform() -> transforms.Compose: |
| """ |
| Returns the deterministic inference transform pipeline. |
| |
| Steps |
| βββββ |
| 1. Resize shortest edge to 256 px (preserves aspect ratio). |
| 2. Centre-crop to 224 Γ 224. |
| 3. Convert PIL image to float32 tensor in [0, 1]. |
| 4. Normalize with ImageNet mean / std. |
| """ |
| return transforms.Compose([ |
| transforms.Resize(256, interpolation=transforms.InterpolationMode.BICUBIC), |
| transforms.CenterCrop(TARGET_SIZE), |
| transforms.ToTensor(), |
| transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD), |
| ]) |
|
|
|
|
|
|
|
|
| |
| class StainJitter: |
| """ |
| Randomly perturb H&E stain concentrations in HED colour space. |
| |
| Why this works |
| ββββββββββββββ |
| H&E-stained slides vary in colour between labs due to differences in |
| staining batches, fixation protocols, and scanner calibrations. |
| Standard RGB colour jitter doesn't model this β it shifts all three |
| channels independently. StainJitter works in HED space (Haematoxylin, |
| Eosin, DAB), which directly corresponds to the actual stains in the tissue. |
| Perturbing HED channels simulates real-world staining variation without |
| needing a reference image or external library. |
| |
| Implementation |
| ββββββββββββββ |
| Uses the Ruifrok & Johnston HED deconvolution matrix to decompose |
| RGB into stain concentrations, perturbs each channel with a random |
| scale (alpha) and shift (beta), then reconstructs the RGB image. |
| Pure NumPy β no external dependencies beyond what is already installed. |
| |
| Parameters |
| ---------- |
| strength : float |
| Controls the magnitude of perturbation. |
| 0.05 = Β±5% scale variation + Β±5% shift variation per channel. |
| Typical values: 0.03 (subtle) to 0.10 (aggressive). |
| p : float |
| Probability of applying the transform. Default 0.5. |
| """ |
|
|
| |
| |
| HED = np.array([ |
| [0.6500286, 0.7044536, 0.2860126], |
| [0.7044522, 0.4956977, 0.5079795], |
| [0.2860126, 0.5079795, 0.8128560], |
| ], dtype=np.float64) |
|
|
| |
| HED_INV = np.linalg.inv(HED) |
|
|
| def __init__(self, strength: float = 0.05, p: float = 0.5) -> None: |
| self.strength = strength |
| self.p = p |
|
|
| def __call__(self, img: "Image.Image") -> "Image.Image": |
| if np.random.random() > self.p: |
| return img |
|
|
| |
| rgb = np.array(img, dtype=np.float64) / 255.0 |
|
|
| |
| |
| od = -np.log(np.clip(rgb, 1e-6, 1.0)) |
|
|
| |
| |
| hed = od @ self.HED_INV |
|
|
| |
| alpha = np.random.uniform( |
| 1.0 - self.strength, |
| 1.0 + self.strength, |
| size=(1, 1, 3), |
| ) |
| beta = np.random.uniform( |
| -self.strength, |
| +self.strength, |
| size=(1, 1, 3), |
| ) |
| hed_perturbed = hed * alpha + beta |
|
|
| |
| od_reconstructed = hed_perturbed @ self.HED |
| rgb_out = np.exp(-od_reconstructed) |
| rgb_out = np.clip(rgb_out, 0.0, 1.0) |
|
|
| return Image.fromarray((rgb_out * 255).astype(np.uint8), mode="RGB") |
|
|
| def build_training_transform() -> transforms.Compose: |
| """ |
| Augmentation pipeline for fine-tuning. |
| Included for completeness; inference always uses build_inference_transform(). |
| """ |
| return transforms.Compose([ |
| StainJitter(strength=0.05, p=0.5), |
| transforms.RandomResizedCrop(TARGET_SIZE, scale=(0.8, 1.0)), |
| transforms.RandomHorizontalFlip(), |
| transforms.RandomVerticalFlip(), |
| transforms.ColorJitter(brightness=0.2, contrast=0.2, |
| saturation=0.1), |
| transforms.RandomRotation(degrees=15), |
| transforms.ToTensor(), |
| transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD), |
| ]) |
|
|
|
|
| |
| class ImagePreprocessor: |
| """ |
| Accepts multiple input types and returns a (1, 3, 224, 224) tensor. |
| |
| Supported inputs |
| ββββββββββββββββ |
| - str / pathlib.Path : local file path to PNG / JPG / TIFF |
| - PIL.Image.Image : already-loaded PIL image |
| - np.ndarray : HxWx3 uint8 or float32 array |
| - torch.Tensor : CxHxW or 1xCxHxW (skips PIL stage) |
| """ |
|
|
| def __init__(self) -> None: |
| self._transform = build_inference_transform() |
|
|
| def __call__( |
| self, |
| image: Union[str, Path, "Image.Image", np.ndarray, torch.Tensor], |
| ) -> torch.Tensor: |
| """ |
| Parameters |
| ---------- |
| image : see supported inputs above |
| |
| Returns |
| ------- |
| torch.Tensor |
| Shape (1, 3, 224, 224), dtype float32, ImageNet-normalised. |
| """ |
| pil_image = self._to_pil(image) |
| tensor = self._transform(pil_image) |
| return tensor.unsqueeze(0) |
|
|
| |
| @staticmethod |
| def _to_pil(image) -> "Image.Image": |
| if isinstance(image, (str, Path)): |
| return Image.open(image).convert("RGB") |
|
|
| if isinstance(image, Image.Image): |
| return image.convert("RGB") |
|
|
| if isinstance(image, np.ndarray): |
| if image.dtype != np.uint8: |
| image = (np.clip(image, 0, 1) * 255).astype(np.uint8) |
| if image.ndim == 2: |
| image = np.stack([image] * 3, axis=-1) |
| return Image.fromarray(image, mode="RGB") |
|
|
| if isinstance(image, torch.Tensor): |
| t = image.squeeze(0) if image.ndim == 4 else image |
| arr = (t.permute(1, 2, 0).cpu().numpy() * 255).astype(np.uint8) |
| return Image.fromarray(arr, mode="RGB") |
|
|
| raise TypeError( |
| f"Unsupported image type: {type(image)}. " |
| "Expected str, Path, PIL.Image, np.ndarray, or torch.Tensor." |
| ) |
|
|