High-performance whole slide image reader for digital pathology
FastSlide is a modern C++20 library for reading whole slide images (WSI) with first-class Python support. Designed for AI/ML workflows, it provides thread-safe, efficient access to multiple slide formats.
📖 Documentation: https://docs.aifo.dev/fastslide/
📜 License: Apache 2.0
- 🚀 High Performance - Thread-safe design
- 🐍 Python & C++ - Complete APIs for both languages
- 🔧 PyTorch Ready - Works seamlessly with DataLoader multi-worker loading
- 🔬 Multi-dimensional - Channels, focal planes (Z) and time points (T): full
C·X·Y·Z·Tselection for OME-TIFF and CZI - 🖼️ Multiple images / scenes - Zeiss CZI scenes and Olympus VSI navigator/region images exposed via
slide.images - 📁 Multiple Formats:
- SVS (Aperio)
- QPTIFF (including mIF)
- OME-TIFF (including Z/T stacks)
- OME.ZARR (CXY, XYC only)
- MRXS (3DHISTECH, including mIF)
- iSyntax (Philips)
- Philips TIFF
- Hamamatsu NDPI (including Z stacks)
- Generic TIFF
- CZI (Zeiss, including Z/T stacks)
- Ventana (BIF)
- Olympus (VSI, including Z stacks)
FastSlide is also available as a QuPath extension, so you can open every FastSlide-supported format directly in QuPath — no coding required. The easiest way to install it is through QuPath's extension catalog (QuPath 0.6 or newer):
-
Open QuPath and go to
Extensions→Manage extensions. -
Click
Manage extension catalogs→Add. -
Enter the catalog URL and confirm:
https://github.com/NKI-AI/qupath-extension-catalog -
Back in the extension manager, click the
+next to QuPath FastSlide extension to install it. Restart QuPath if prompted.
QuPath will then read whole-slide images via FastSlide and notify you when a new version of the extension is published. Sources and manual install instructions live at NKI-AI/qupath-extension-fastslide.
FastSlide can be installed from a prebuilt wheel, or built from source with
either Meson (simplest, integrates with pip/uv) or Bazel (hermetic,
used for the official release wheels).
uv pip install fastslideFastSlide is a regular Meson project. All native dependencies have wrap fallbacks, so a checkout builds standalone with nothing but a C++20 compiler, Meson (>= 1.3) and Ninja:
git clone https://github.com/NKI-AI/fastslide
cd fastslide
meson setup builddir
meson compile -C builddir
# Run the C++ test suite.
meson test -C builddirThis builds the C++ library, the fastslidetool CLI and the C API. See
meson.options for the available options (e.g. -Dbuild_tool=false,
-Dbuild_c_api=false, -Djpeg_decoder=jpgd).
Python package via meson-python — the Python bindings are wired up through meson-python, so the wheel builds straight from the source tree with standard Python tooling:
# Editable (development) install: compiles the native extension with Meson.
uv pip install -e .
# Or build a wheel.
uv buildpip install -e . / python -m build work the same way. The build is
self-contained: all codecs are statically linked into the _fastslide
extension, so the resulting wheel has no native runtime dependencies.
FastSlide is a Bazel module. Builds are
driven by bzlmod and we pin a
specific Bazel version through .bazelversion; the recommended launcher is
bazelisk, which picks up that file
automatically.
git clone https://github.com/NKI-AI/fastslide
cd fastslide
# Build everything (C++ library, Python bindings, Go bindings, WASM target).
bazelisk build //...
# Run the C++ test suite.
bazelisk test //...Wheels are platform-specific because they bundle the native C++ extension. Each
Python version has its own Bazel target: //python:fastslide_wheel_cp310 through
//python:fastslide_wheel_cp314 (Python 3.10–3.14). Unlike the Meson path,
Bazel can also cross-compile wheels for other platforms.
Current platform — build on the host OS/arch without cross-compilation:
# Example: Python 3.11 wheel for the machine you are on.
bazelisk build //python:fastslide_wheel_cp311The .whl file appears under bazel-bin/python/.
Cross-compilation — build wheels for other platforms using the Zig-backed
hermetic toolchains (--config=hermetic in .bazelrc):
# Example: Linux x86_64 wheel for Python 3.11, e.g. from macOS.
bazelisk build --config=hermetic --platforms=//platforms:linux_x86_64 \
//python:fastslide_wheel_cp311Supported platform keys: linux_x86_64, linux_arm64, darwin_x86_64,
darwin_aarch64, windows_x86_64. When building for the host macOS
architecture from macOS, the native toolchain is used instead of hermetic Zig.
Batch builds — tools/build_wheels.py drives Bazel for multiple platforms
and Python versions and copies wheels into artifacts/wheels/:
# All supported platforms and Python versions.
python tools/build_wheels.py
# Subset, e.g. one platform and one Python tag.
python tools/build_wheels.py --platform linux_x86_64 --python cp311
# Continue after individual failures.
python tools/build_wheels.py --keep-goingRun python tools/build_wheels.py --help for the full option list.
To consume FastSlide from another Bazel module, add it to your MODULE.bazel:
bazel_dep(name = "fastslide", version = "0.7.0")
git_override(
module_name = "fastslide",
remote = "https://github.com/NKI-AI/fastslide.git",
commit = "<pin a recent commit SHA>",
)and depend on @fastslide//:fastslide_lib (C++) or @fastslide//python:fastslide (Python).
import fastslide
# Open a slide using context manager (automatically closes when done)
with fastslide.FastSlide.from_file_path('slide.svs') as slide:
# Get slide information
print(f"Dimensions: {slide.dimensions}") # (width, height) at level 0
print(f"Levels: {slide.level_count}") # Number of pyramid levels
print(f"Resolution: {slide.mpp} µm/pixel")
print(f"Format: {slide.format}") # e.g., "SVS", "MRXS", "QPTIFF"
# Read a region at full resolution (level 0)
region = slide.read_region(
location=(1000, 2000), # (x, y) in level-native coordinates
level=0, # pyramid level
size=(512, 512) # (width, height)
).numpy()
# region is a numpy array: shape (512, 512, 3), dtype uint8import fastslide
# Open a slide without context manager
slide = fastslide.FastSlide.from_file_path('slide.mrxs')
try:
# Work with the slide
region = slide.read_region(location=(0, 0), level=0, size=(1024, 1024)).numpy()
# Get slide properties
props = slide.properties
print(f"Scanner: {props.get('scanner_model', 'Unknown')}")
print(f"Magnification: {props.get('objective_magnification', 'N/A')}")
finally:
# Always close the slide to release resources
slide.close()import fastslide
with fastslide.FastSlide.from_file_path('slide.tiff') as slide:
# Get information about all pyramid levels
print(f"Level count: {slide.level_count}")
print(f"Level dimensions: {slide.level_dimensions}")
print(f"Level downsamples: {slide.level_downsamples}")
# Read the same region at different resolutions
location = (10000, 15000)
size = (256, 256)
# Full resolution (level 0)
region_l0 = slide.read_region(location=location, level=0, size=size).numpy()
# 4× downsampled (level 2)
# Convert coordinates to level 2 space
x_l2, y_l2 = slide.convert_level0_to_level_native(
location[0], location[1], level=2
)
region_l2 = slide.read_region(location=(x_l2, y_l2), level=2, size=size).numpy()
# Find best level for a specific downsample factor
best_level = slide.get_best_level_for_downsample(8.0)
print(f"Best level for 8× downsample: {best_level}")OME-TIFF and CZI files can store more than a single 2D plane: multiple
fluorescence channels (C), a Z focal stack, and a T time series.
FastSlide exposes these as a C·X·Y·Z·T hyper-volume. Each read_region
selects one (z, t) plane and returns all channels of that plane; z and t
default to 0, so 2D/brightfield code keeps working unchanged.
import fastslide
with fastslide.FastSlide.from_file_path('stack.ome.tiff') as slide:
# Inspect the stack extent.
print(f"Focal planes (Z): {slide.z_count}")
print(f"Time points (T): {slide.t_count}")
print(f"Z spacing: {slide.z_spacing_um} µm") # None if unknown
print(f"T interval: {slide.t_interval_s} s") # None if unknown
# Or as a single dict.
print(slide.get_stack_info())
# {'z_count': 5, 't_count': 7, 'z_spacing_um': 0.5, 't_interval_s': 10.0}
# Read the 3rd focal plane at the 2nd time point.
region = slide.read_region(
location=(0, 0),
level=0,
size=(512, 512),
z=2, # focal-plane index (0 = first plane)
t=1, # time-point index (0 = first time point)
).numpy()
# Fluorescence planes carry independent channels (not RGB). The numpy
# shape depends on the image's internal layout (see "Pixel layout" below):
# - interleaved / CONTIGUOUS -> (height, width, channels) [HWC]
# - band-separate / SEPARATE -> (channels, height, width) [CHW]
for t in range(slide.t_count):
for z in range(slide.z_count):
img = slide.read_region((0, 0), level=0, size=(512, 512), z=z, t=t)
# Normalize to a known layout instead of assuming one:
arr = img.to_interleaved().numpy() # (512, 512, channels), HWCPer-image stacks are also available on individual images of a multi-image
slide via slide.images[i].read_region(..., z=, t=) and
slide.images[i].get_stack_info().
The byte layout of a returned Image is not fixed — it follows the source's
internal organization, exposed via image.planar_config:
planar_config |
Memory layout | numpy() shape |
|---|---|---|
PlanarConfig.CONTIGUOUS |
interleaved, HWC | (height, width, channels) |
PlanarConfig.SEPARATE |
band-separate,CHW | (channels, height, width) |
Brightfield RGB is typically CONTIGUOUS; multi-channel fluorescence is
typically SEPARATE. Don't assume an axis order — inspect it, or normalize:
img = slide.read_region((0, 0), level=0, size=(512, 512))
img.planar_config # PlanarConfig.CONTIGUOUS or PlanarConfig.SEPARATE
img.is_interleaved # True for HWC
img.is_separate # True for CHW
# Force a specific layout (no-op + zero-copy if already in that layout).
hwc = img.to_interleaved().numpy() # (H, W, C)
chw = img.to_separate().numpy() # (C, H, W)Some files hold more than one navigable image. Zeiss CZI files expose each
acquisition scene as its own image; Olympus VSI files expose a
low-resolution "navigator" alongside one or more high-resolution "region"
images. FastSlide surfaces these through slide.images, an indexable sequence
of SlideImageViews. The top-level FastSlide accessors (dimensions,
level_count, read_region, ...) always forward to the primary image, so
single-image code is unaffected.
import fastslide
with fastslide.FastSlide.from_file_path('scan.czi') as slide:
print(f"Number of images: {slide.num_images}") # == len(slide.images)
print(f"Image names: {slide.images.names()}") # e.g. ['scene 0', 'scene 1']
print(f"Primary index: {slide.images.primary_index}")
# Iterate every image (scene) and read each independently.
for img in slide.images:
print(f"[{img.index}] {img.name}: {img.dimensions}, "
f"{img.level_count} levels, Z={img.z_count}, T={img.t_count}")
region = img.read_region(location=(0, 0), level=0, size=(512, 512)).numpy()
# Address a specific image by index, with full level + Z/T selection.
scene1 = slide.images[1]
tile = scene1.read_region((0, 0), level=0, size=(1024, 1024), z=0, t=0).numpy()
# The primary image is also directly accessible.
primary = slide.images.primaryEach SlideImageView is a full navigator with its own pyramid (level_count,
level_dimensions, level_downsamples), resolution (mpp), and Z/T stack
(z_count, t_count, get_stack_info()) — so an individual scene can itself
be a C·X·Y·Z·T volume.
import fastslide
from PIL import Image
with fastslide.FastSlide.from_file_path('slide.svs') as slide:
# Check what associated images are available
associated = slide.associated_images
print(f"Available images: {associated.keys()}") # e.g., ['thumbnail', 'macro', 'label']
# Read thumbnail (lazy loaded)
if 'thumbnail' in associated:
thumbnail = associated['thumbnail'] # numpy array
# Convert to PIL Image and save
img = Image.fromarray(thumbnail)
img.save('thumbnail.png')
# Get dimensions without loading
dims = associated.get_dimensions('thumbnail')
print(f"Thumbnail size: {dims}")#include "fastslide/slide_reader.h"
#include "fastslide/runtime/reader_registry.h"
// Create reader
auto reader = fastslide::runtime::GetGlobalRegistry()
.CreateReader("slide.svs");
// Read region
fastslide::RegionSpec spec{
.top_left = {1000, 2000},
.size = {512, 512},
.level = 0
};
auto image = reader->ReadRegion(spec);For OME-TIFF and CZI stacks, RegionSpec::plane selects the focal plane (Z)
and time point (T); both default to the first plane, so 2D reads are
unaffected. Query the stack extent with GetStackInfo().
// Stack extent and physical spacing.
const fastslide::StackInfo stack = reader->GetStackInfo();
// stack.z_count, stack.t_count : number of selectable planes (>= 1)
// stack.z_spacing_um, stack.t_interval_s : std::optional<double> (physical step)
// Read the 3rd focal plane at the 2nd time point.
fastslide::RegionSpec spec{
.top_left = {0, 0},
.size = {512, 512},
.level = 0,
.plane = {.z = 2, .t = 1},
};
auto result = reader->ReadRegion(spec); // aifocore::Result<Image>
const fastslide::Image& image = result.value(); // check result.ok() first
// Channel memory layout follows the source and is reported per Image; do not
// assume interleaved vs. band-separate. Normalize when you need a fixed order.
const fastslide::PlanarConfig layout = image.GetPlanarConfig();
const bool interleaved = image.IsInterleaved(); // kContiguous (HWC)
const bool separate = image.IsSeparate(); // kSeparate (CHW)
auto hwc = image.ToInterleaved(); // zero-copy/no-op if already interleaved
auto chw = image.ToPlanar(); // zero-copy/no-op if already separateGetImageCount(), GetImageNames() and GetImage(index) expose every
navigable image (Zeiss CZI scenes, Olympus VSI navigator/region images). The
reader's own ReadRegion/GetStackInfo forward to GetPrimaryImageIndex().
const int count = reader->GetImageCount();
const std::vector<std::string> names = reader->GetImageNames();
for (int i = 0; i < count; ++i) {
auto image_or = reader->GetImage(i); // aifocore::Result<const SlideImage*>
if (!image_or.ok()) {
continue;
}
const fastslide::SlideImage& image = *image_or.value();
// Each image has its own name, pyramid, channels and Z/T stack.
const std::string name = image.GetName();
const fastslide::StackInfo stack = image.GetStackInfo();
fastslide::RegionSpec spec{
.top_left = {0, 0},
.size = {512, 512},
.level = 0,
.plane = {.z = 0, .t = 0},
};
auto region = image.ReadRegion(spec); // aifocore::Result<Image>
}from torch.utils.data import DataLoader
# Each worker gets its own slide reader
dataloader = DataLoader(
dataset,
batch_size=32,
num_workers=8, # Safe for multi-worker loading
shuffle=True
)FastSlide uses level-native coordinates for region reading. This is where FastSlide clearly deviates from OpenSlide, which always represents the coordinates in level 0.
# Level 0: 10000 × 8000 px (full resolution)
# Level 1: 5000 × 4000 px (2× downsample)
# Level 2: 2500 × 2000 px (4× downsample)
# Read 512×512 region from level 2 at position (100, 200)
region = slide.read_region((100, 200), level=2, size=(512, 512)).numpy()
# Convert coordinates between levels if needed
x0, y0 = slide.convert_level_native_to_level0(100, 200, level=2)
# Returns: (400, 800) - the level-0 equivalent📖 Complete documentation: https://docs.aifo.dev/fastslide/
We welcome contributions. Please open an issue to discuss what you would like to change, or jump straight into a pull request.
FastSlide incorporates the following third-party software into its source:
-
SHA-256 implementation from sha-2 by Alain Mosnier
- Licensed under: The Unlicense or Zero Clause BSD license
- Used for: Quick hash computation compatible with OpenSlide
-
unordered_dense from martinus/unordered_dense by Martin Leitner-Ankerl
- Licensed under: MIT License
- Used for: Fast hashmap/hashset for spatial lookup in the Mirax format
-
lodepng from vandeve/lodepng by Lode Vandevenne
- Licensed under: Zlib License
- Used for: Decoding PNG in file formats and to write png in examples.
-
pugixml: from pugixml.org
- Licensed under: MIT License
- Used for: Parsing of XML headers
-
yxml: from https://dev.yorhel.nl/yxml
- Licensed under: MIT License
- Used for: Parsing of XML headers
-
tifffile: from cgohlke/tifffile/ by Christoph Gohlke
- Licensed under: BSD-3-Clause
- Used for: Test data files
-
jpeg-compressor: from richgel999/jpeg-compressor by richgel999
- Licensed under: Public domain
- Used for: Alternative JPEG decompression, required in WASM builds.
-
thread-pool: from bshoshany/thread-pool by Barak Shoshany
- Licensed under: MIT License
- Used for: Creating thread pool for decoding, etc.
-
libisyntax: from amspath/libisyntax by Pieter Valkema
- Licensed under: BSD-2 License
- Used for: iSyntax decoding
- Modifications: Library has been stripped to the minimal requirements.
-
jxrlib from 4creators/jxrlib by Microsoft
- Licensed under: BSD-2-Clause License
- Used for: Decoding of JPEG XR tiles in the Zeiss CZI reader
- Modifications: Library has been modified to compile with Bazel and unused files removed.
Several other libraries are used, but these are dynamically (or statically where appropriate) linked.
@software{fastslide,
title = {FastSlide: High-performance whole slide image reader},
author = {George Yiasemis, Rolf Harkes and Jonas Teuwen},
year = {2025},
url = {https://github.com/NKI-AI/fastslide}
}- Documentation: https://docs.aifo.dev/fastslide/
- Issues: GitHub Issues
- Discussions: GitHub Discussions
FastSlide is licensed under the Apache License, Version 2.0.
See LICENSE for full details.