Processing Whole Slide Images with OpenSlide and PyTorch
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.