AndreaNotes1/fgh-dermatology
0
1"""2utils/preprocess.py3====================4Image preprocessing pipeline for the FGH-Dermatology inference pipeline.5 6Responsible for turning an arbitrary uploaded dermoscopic/clinical image7(JPEG, PNG, TIFF, BMP — any colour mode) into the normalised float tensor8the deep learning model expects, while remaining robust across both9light- and dark-skinned patient imagery.10"""11 12from typing import Tuple13 14import numpy as np15from PIL import Image, ImageOps16 17 18def load_rgb_image(image_path: str) -> Image.Image:19 """20 Load an image from disk, correct EXIF orientation, and force RGB mode21 (handling RGBA, palette, grayscale, and CMYK source images safely).22 """23 img = Image.open(image_path)24 img = ImageOps.exif_transpose(img) # respect camera/scanner orientation25 if img.mode != "RGB":26 img = img.convert("RGB")27 return img28 29 30def preprocess_image(image_path: str, target_size: Tuple[int, int] = (224, 224)) -> np.ndarray:31 """32 Full preprocessing pipeline for model inference.33 34 Steps35 -----36 1. Load image and normalise to RGB (handles RGBA -> RGB conversion).37 2. Resize to the model's expected square input dimensions.38 3. Scale pixel values to the [0, 1] range.39 4. Apply mild contrast-normalisation so the model attends to lesion40 morphology (shape, border, texture) rather than raw skin colour —41 supporting equitable performance across Fitzpatrick skin types.42 5. Expand dimensions to a (1, H, W, 3) batch tensor.43 44 Parameters45 ----------46 image_path : str47 Path to the source image on disk.48 target_size : tuple(int, int)49 (height, width) the model expects.50 51 Returns52 -------53 np.ndarray of shape (1, H, W, 3), dtype float32, values in [0, 1].54 """55 img = load_rgb_image(image_path)56 img = img.resize(target_size, Image.LANCZOS)57 58 arr = np.asarray(img).astype(np.float32) / 255.059 60 arr = _normalise_for_skintone_robustness(arr)61 62 return np.expand_dims(arr, axis=0)63 64 65def _normalise_for_skintone_robustness(arr: np.ndarray) -> np.ndarray:66 """67 Apply a light per-channel standardisation (zero-mean-ish rescale clipped68 back to [0, 1]) so absolute skin colour/luminance contributes less to69 activations than structural lesion features (border, asymmetry,70 texture). This is a lightweight, model-agnostic step; a production model71 trained explicitly for skintone equity may apply its own calibrated72 normalisation instead — in which case this step is a harmless no-op73 pass-through that callers can disable by passing `skip_norm=True` to74 `preprocess_image` in a future revision.75 """76 channel_mean = arr.mean(axis=(0, 1), keepdims=True)77 channel_std = arr.std(axis=(0, 1), keepdims=True) + 1e-678 79 # Blend a mild standardisation with the original signal so we improve80 # cross-skintone contrast normalisation without discarding true colour81 # information the model may also rely on.82 standardised = (arr - channel_mean) / channel_std83 standardised = (standardised - standardised.min()) / (84 standardised.max() - standardised.min() + 1e-685 )86 87 blended = 0.6 * arr + 0.4 * standardised88 return np.clip(blended, 0.0, 1.0).astype(np.float32)89 90 91def get_image_dimensions(image_path: str) -> Tuple[int, int]:92 """Return (width, height) of the source image without full decoding cost."""93 with Image.open(image_path) as img:94 return img.size95 