Tutorial

Processing Whole Slide Images with OpenSlide and PyTorch

Difficulty: Intermediate Time: 15 min read

Introduction

In computational pathology, Whole Slide Images (WSIs) are digitized tissue biopsies. These files are gigapixel in sizeโ€”often exceeding 100,000 x 100,000 pixels. You cannot load a WSI directly into RAM, let alone VRAM. In this tutorial, we will use the open-source C library OpenSlide to efficiently extract small, high-resolution tiles (patches) from a WSI, and process them using PyTorch.

Architecture Overview


graph TD
    WSI[(Whole Slide Image .svs)] --> OpenSlide[OpenSlide C-Library]
    OpenSlide --> Pyramid[WSI Pyramid levels 0, 1, 2...]
    
    Pyramid --> |Level 0| PatchGen[DeepZoom Patch Generator]
    
    PatchGen --> Filter[Filter Background/White space]
    Filter --> ValidCoords[(Valid Tissue Coordinates)]
    
    ValidCoords --> Dataset[PyTorch Dataset]
    Dataset --> |__getitem__| Dataloader[PyTorch DataLoader]
    Dataloader --> CNN(CNN / Vision Transformer)
        

Prerequisites

  • Python 3.8+
  • PyTorch, torchvision
  • OpenSlide (must be installed at the OS level)
  • A WSI file (e.g., .svs, .ndpi, or .tiff)

Step 1: Install Dependencies

Because OpenSlide is a C library, you must install the binaries before installing the Python wrapper.

# Ubuntu/Debian
sudo apt-get install openslide-tools
pip install openslide-python torch torchvision pillow matplotlib

Step 2: Inspecting the WSI Pyramid

WSIs are stored as "pyramids": the file contains the image at multiple resolutions. Level 0 is the highest resolution (native magnification, e.g., 40x), while higher levels are progressively downsampled.

import openslide

# Open the Whole Slide Image
wsi = openslide.OpenSlide("biopsy.svs")

print(f"Number of levels: {wsi.level_count}")
print(f"Dimensions of highest resolution (Level 0): {wsi.dimensions}")

# Print the dimensions of all levels
for i in range(wsi.level_count):
    print(f"Level {i} dimensions: {wsi.level_dimensions[i]}, Downsample factor: {wsi.level_downsamples[i]:.2f}")

Step 3: Extracting Patches (Tiling)

To feed this image to a CNN, we must read small patches (e.g., 256x256 pixels) at a specific magnification level.

from PIL import Image

# Parameters for extraction
level = 0
patch_size = (256, 256)

# The (x, y) coordinate is always relative to Level 0, even if extracting from a lower resolution level!
# Let's extract a patch from the center of the image
x_center = wsi.dimensions[0] // 2
y_center = wsi.dimensions[1] // 2

# Top-left coordinate of the patch
top_left_x = x_center - (patch_size[0] // 2)
top_left_y = y_center - (patch_size[1] // 2)

# Read the region
# read_region returns an RGBA PIL Image
patch = wsi.read_region((top_left_x, top_left_y), level, patch_size)

# Convert to RGB (drop the alpha channel)
patch_rgb = patch.convert("RGB")

Step 4: Using DeepZoomGenerator to Handle Edge Cases

Writing manual coordinate math is prone to errors, especially at the edges of the slide. OpenSlide provides a DeepZoomGenerator that safely handles tiling logic, including partial tiles at the image borders.

from openslide.deepzoom import DeepZoomGenerator

# Create a DeepZoom generator for 256x256 patches with 0 overlap
dz = DeepZoomGenerator(wsi, tile_size=256, overlap=0, limit_bounds=True)

# DeepZoom levels are inverted compared to OpenSlide levels!
# The highest resolution level in DeepZoom is dz.level_count - 1
dz_highest_level = dz.level_count - 1

# Get the grid size at this level (columns, rows)
cols, rows = dz.level_tiles[dz_highest_level]

print(f"Total tiles at highest resolution: {cols * rows}")

# Extract a specific tile by (col, row) address
# This safely handles edge padding automatically
tile = dz.get_tile(dz_highest_level, address=(cols//2, rows//2))
tile_rgb = tile.convert("RGB")

Step 5: Creating a PyTorch Dataset

In a real workflow, we generate a grid of coordinates across the tissue (filtering out empty background areas) and use a PyTorch Dataset to load these patches on the fly during training.

from torch.utils.data import Dataset, DataLoader
from torchvision import transforms

class WSIPatchDataset(Dataset):
    def __init__(self, wsi_path, addresses, tile_size=256, transform=None):
        self.wsi_path = wsi_path
        self.addresses = addresses # List of (col, row) tuples
        self.tile_size = tile_size
        self.transform = transform
        
        # OpenSlide objects cannot be pickled, so we open it in __getitem__ or initialize per worker
        self.wsi = None
        self.dz = None

    def __len__(self):
        return len(self.addresses)

    def __getitem__(self, idx):
        if self.dz is None:
            self.wsi = openslide.OpenSlide(self.wsi_path)
            self.dz = DeepZoomGenerator(self.wsi, tile_size=self.tile_size, overlap=0, limit_bounds=True)
            self.highest_level = self.dz.level_count - 1
            
        col, row = self.addresses[idx]
        patch = self.dz.get_tile(self.highest_level, (col, row)).convert("RGB")
        
        if self.transform:
            patch = self.transform(patch)
            
        return patch

# Setup Dataloader
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])

# Assuming 'valid_addresses' is a list of (col,row) tuples where tissue is present (not just white background)
# dataset = WSIPatchDataset("biopsy.svs", valid_addresses, transform=transform)
# dataloader = DataLoader(dataset, batch_size=32, num_workers=4)

Conclusion

OpenSlide is the definitive bridge between massive proprietary pathology formats and standard Python machine learning frameworks. By combining it with PyTorch's lazy-loading DataLoaders and the DeepZoom generator, you can efficiently train models on gigapixel images without ever risking an Out-Of-Memory (OOM) error, enabling breakthroughs in automated tumor detection and grading.