fgh-dermatology / utils /preprocess.py
AndreaNotes1's picture
Upload 14 files
b8a4dcd verified
Raw History Blame Contribute Delete
3.45 kB
"""
utils/preprocess.py
====================
Image preprocessing pipeline for the FGH-Dermatology inference pipeline.
Responsible for turning an arbitrary uploaded dermoscopic/clinical image
(JPEG, PNG, TIFF, BMP — any colour mode) into the normalised float tensor
the deep learning model expects, while remaining robust across both
light- and dark-skinned patient imagery.
"""
from typing import Tuple
import numpy as np
from PIL import Image, ImageOps
def load_rgb_image(image_path: str) -> Image.Image:
"""
Load an image from disk, correct EXIF orientation, and force RGB mode
(handling RGBA, palette, grayscale, and CMYK source images safely).
"""
img = Image.open(image_path)
img = ImageOps.exif_transpose(img) # respect camera/scanner orientation
if img.mode != "RGB":
img = img.convert("RGB")
return img
def preprocess_image(image_path: str, target_size: Tuple[int, int] = (224, 224)) -> np.ndarray:
"""
Full preprocessing pipeline for model inference.
Steps
-----
1. Load image and normalise to RGB (handles RGBA -> RGB conversion).
2. Resize to the model's expected square input dimensions.
3. Scale pixel values to the [0, 1] range.
4. Apply mild contrast-normalisation so the model attends to lesion
morphology (shape, border, texture) rather than raw skin colour —
supporting equitable performance across Fitzpatrick skin types.
5. Expand dimensions to a (1, H, W, 3) batch tensor.
Parameters
----------
image_path : str
Path to the source image on disk.
target_size : tuple(int, int)
(height, width) the model expects.
Returns
-------
np.ndarray of shape (1, H, W, 3), dtype float32, values in [0, 1].
"""
img = load_rgb_image(image_path)
img = img.resize(target_size, Image.LANCZOS)
arr = np.asarray(img).astype(np.float32) / 255.0
arr = _normalise_for_skintone_robustness(arr)
return np.expand_dims(arr, axis=0)
def _normalise_for_skintone_robustness(arr: np.ndarray) -> np.ndarray:
"""
Apply a light per-channel standardisation (zero-mean-ish rescale clipped
back to [0, 1]) so absolute skin colour/luminance contributes less to
activations than structural lesion features (border, asymmetry,
texture). This is a lightweight, model-agnostic step; a production model
trained explicitly for skintone equity may apply its own calibrated
normalisation instead — in which case this step is a harmless no-op
pass-through that callers can disable by passing `skip_norm=True` to
`preprocess_image` in a future revision.
"""
channel_mean = arr.mean(axis=(0, 1), keepdims=True)
channel_std = arr.std(axis=(0, 1), keepdims=True) + 1e-6
# Blend a mild standardisation with the original signal so we improve
# cross-skintone contrast normalisation without discarding true colour
# information the model may also rely on.
standardised = (arr - channel_mean) / channel_std
standardised = (standardised - standardised.min()) / (
standardised.max() - standardised.min() + 1e-6
)
blended = 0.6 * arr + 0.4 * standardised
return np.clip(blended, 0.0, 1.0).astype(np.float32)
def get_image_dimensions(image_path: str) -> Tuple[int, int]:
"""Return (width, height) of the source image without full decoding cost."""
with Image.open(image_path) as img:
return img.size