Skip to content

Transforms

Transforms augment and preprocess patches after extraction and filtering. wsistream includes pathology-specific transforms that are not available in general-purpose libraries. For standard vision augmentations, use albumentations through the included wrapper.

All transforms operate on numpy arrays (H, W, 3) and preserve uint8 dtype, unless they are explicitly a normalization step (which outputs float32 and should be last in the chain).

Transform comparison
Each row shows the same source patches after applying a single transform. HEDColorAugmentation simulates staining variation; RandomFlipRotate applies random flips and 90-degree rotations; ResizeTransform changes spatial resolution; AlbumentationsWrapper applies standard vision augmentations.

Pathology-specific

HEDColorAugmentation

Decomposes the image into Hematoxylin, Eosin, and DAB stain channels, perturbs each channel i as s_i' = alpha_i * s_i + beta_i, and converts back to RGB. This simulates staining variation across labs and scanners. The perturbation was introduced by Tellez et al. (2018), which draws alpha and beta per channel from two uniform distributions; Tellez et al. (2019) reports the intensity ranges. Also used by Midnight (Karasikov et al., 2025).

from wsistream.transforms import HEDColorAugmentation

transform = HEDColorAugmentation(
    sigma=0.05,       # alpha ~ U(1 - sigma, 1 + sigma); 0.05 = Tellez "light", 0.2 = "strong"
    sigma_bias=None,  # beta ~ U(-sigma_bias, sigma_bias); None reuses sigma
    seed=None,        # random seed
)

One sigma drives both terms, as defined by Tellez et al. (2018) and also implemented by StainTools and HistomicsTK. The RGB/HED conversion follows the paper's additive-epsilon equations directly, without clamping stain concentrations. The perturbation is applied in optical-density space, so sigma needs no rescaling. Set sigma_bias to decouple the two terms, or to 0.0 to drop the additive term.

Alternative: albumentations.HEStain

HEDColorAugmentation always uses skimage's fixed HED deconvolution matrix, so it perturbs stain channels defined by a global average rather than by your slide's actual stain vectors. A.HEStain can estimate the stain matrix from the image when method="macenko" or method="vahadane"; its default method="random_preset" instead picks from predefined matrices. Prefer it when stain vectors vary a lot across your cohort; see Stain augmentation via albumentations below.

NormalizeTransform

Per-channel mean/std normalization. Converts uint8 to float32. Should be the last transform in a chain since it changes the dtype.

Requires explicit mean and std -- there are no defaults. Choose values to match your model's expected normalization.

from wsistream.transforms import NormalizeTransform

# ImageNet normalization
transform = NormalizeTransform(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225))

# Symmetric normalization (maps [0, 255] to [-1, 1])
transform = NormalizeTransform(mean=(0.5, 0.5, 0.5), std=(0.5, 0.5, 0.5))

Note

If your training code handles normalization (e.g., inside the model or the DataLoader collate function), you do not need this here. Avoid double-normalizing.

Utility transforms

ResizeTransform

Resizes to a square target size. Useful when the extraction patch size (e.g., 256) differs from the model input size (e.g., 224).

import cv2
from wsistream.transforms import ResizeTransform

transform = ResizeTransform(
    target_size=224,                     # output width and height
    interpolation=cv2.INTER_LINEAR,      # OpenCV interpolation flag
)

RandomFlipRotate

Random horizontal/vertical flips and 90-degree rotations. Standard for pathology since tissue orientation is arbitrary.

from wsistream.transforms import RandomFlipRotate

transform = RandomFlipRotate(
    p_hflip=0.5,    # probability of horizontal flip
    p_vflip=0.5,    # probability of vertical flip
    p_rot90=0.5,    # probability of 90-degree rotation (1, 2, or 3 quarter turns)
    seed=None,       # random seed
)

Standard augmentations via albumentations

For augmentations like color jitter, Gaussian blur, grayscale conversion, and solarization, use AlbumentationsWrapper:

import albumentations as A
from wsistream.transforms import AlbumentationsWrapper

transform = AlbumentationsWrapper(A.Compose([
    A.HorizontalFlip(p=0.5),
    A.VerticalFlip(p=0.5),
    A.RandomRotate90(p=0.5),
    A.ColorJitter(brightness=0.4, contrast=0.4, saturation=0.2, hue=0.1, p=0.8),
    A.ToGray(p=0.2),
    A.GaussianBlur(blur_limit=7, sigma_limit=(0.1, 2.0), p=0.5),
    A.Solarize(threshold_range=(0.5, 0.5), p=0.2),  # 128/255, normalised
]))

Seeding across DataLoader workers

Albumentations initialises its RNG state at construction time. Under fork-based DataLoader workers the constructed object is copied in memory, so all workers share the same initial state and replay identical augmentations. AlbumentationsWrapper prevents this: it owns an RNG that PatchPipeline reseeds per worker and pushes a derived seed into albumentations via set_random_seed. Set seed on PatchPipeline, not on the wrapper. set_random_seed was added in albumentations 1.4.21 (absent in 1.4.18); with anything older the wrapper warns.

Stain augmentation via albumentations

Albumentations (>= 2.0) includes a built-in HEStain transform that decomposes the image into stain concentration channels, randomly perturbs them, and reconstructs. Its default method="random_preset" draws from predefined stain matrices; pass method="macenko" or "vahadane" to estimate the matrix from each image. This is a more principled alternative to HEDColorAugmentation for simulating staining variation across labs and scanners.

import albumentations as A
from wsistream.transforms import AlbumentationsWrapper

# Macenko-based stain augmentation
transform = AlbumentationsWrapper(A.Compose([
    A.HEStain(
        method="macenko",
        intensity_scale_range=(0.7, 1.3),   # multiplicative perturbation per stain channel
        intensity_shift_range=(-0.2, 0.2),  # additive perturbation per stain channel
        augment_background=False,
        p=0.5,
    ),
]))

# Vahadane-based (better structure preservation)
transform = AlbumentationsWrapper(A.Compose([
    A.HEStain(method="vahadane", p=0.5),
]))

# Random preset (fastest -- uses predefined stain matrices, no per-image SVD)
transform = AlbumentationsWrapper(A.Compose([
    A.HEStain(method="random_preset", p=0.5),
]))

The two key parameters controlling augmentation strength are:

  • intensity_scale_range (default (0.7, 1.3)): multiplicative scaling per stain channel. Narrower range = subtler color variation.
  • intensity_shift_range (default (-0.2, 0.2)): additive shift per stain channel. Controls baseline staining variation.
Stain augmentation comparison
Comparison of stain augmentation methods. Each group shows the original patch followed by three augmented versions. All methods use default parameters (intensity_scale_range=(0.7, 1.3), intensity_shift_range=(-0.2, 0.2), p=1.0).

Composing transforms

Use ComposeTransforms to chain multiple transforms. They are applied in order.

from wsistream.transforms import (
    ComposeTransforms, HEDColorAugmentation, RandomFlipRotate,
    ResizeTransform, NormalizeTransform,
)

pipeline_transforms = ComposeTransforms(transforms=[
    HEDColorAugmentation(sigma=0.05),
    RandomFlipRotate(),
    ResizeTransform(target_size=224),
    NormalizeTransform(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),  # last
])

Writing your own

from wsistream.transforms.base import PatchTransform

class MyTransform(PatchTransform):
    def __call__(self, image):
        # image: numpy array (H, W, 3), uint8
        return ...  # transformed image, same shape