A comprehensive toolkit for Whole Slide Image (WSI) processing, feature extraction, and clustering analysis.
0.6: progress, preset and device are now passed to commands as arguments (see Python API). Upgrading from 0.5? Read
_docs/migration-0.6.md. Changes are listed inCHANGELOG.md.
# From PyPI
pip install wsi-toolbox
# From GitHub (latest)
pip install git+https://github.com/technoplasm/wsi-toolbox.git| Preset | Arch | Params | Dim | HuggingFace |
|---|---|---|---|---|
uni |
ViT-L/16 | 300M | 1024 | MahmoodLab/UNI |
uni2 (default) |
ViT-H/14 | 681M | 1536 | MahmoodLab/UNI2-h |
gigapath |
ViT-g/14 | 1.1B | 1536 | prov-gigapath/prov-gigapath |
gigapath-flash |
ViT-S/16 | 22M | 384 | prov-gigapath/prov-gigapath-flash |
virchow |
ViT-H/14 | 632M | 1280 (concat: 2560) |
paige-ai/Virchow |
virchow2 |
ViT-H/14 | 632M | 1280 (concat: 2560) |
paige-ai/Virchow2 |
h-optimus-0 |
ViT-g/14 | 1.1B | 1536 | bioptimus/H-optimus-0 |
conch15 |
ViT-L/16 | 300M | 1024 (proj: 768) |
MahmoodLab/conchv1_5 |
midnight |
ViT-g/14 | 1.1B | 1536 | SophontAI/OpenMidnight |
phikon2 |
ViT-L/16 | 300M | 1024 | owkin/phikon-v2 |
hibou-b |
ViT-B/14 (4 reg) | 86M | 768 | histai/hibou-b |
hibou-l |
ViT-L/14 (4 reg) | 304M | 1024 | histai/hibou-L |
h0-mini |
ViT-B/14 (4 reg) | 86M | 768 | bioptimus/H0-mini |
phikon |
ViT-B/16 | 86M | 768 | owkin/phikon |
kaiko-midnight |
ViT-g/14 | 1.1B | 3072 | kaiko-ai/midnight |
conch15 has an extra output proj: the contrastive projection (attentional pooler + LayerNorm, 768 dims, not the
CLS token), the input of TITAN. --outputs cls,proj writes both from
one forward pass (conch15/features and conch15_768/features); --outputs proj only the 768-dim one. The old
preset name conch15_768 still works (deprecated): it is conch15 with --outputs proj, written to
conch15_768/features as before.
virchow / virchow2 have an extra output concat: the CLS token concatenated with the mean of the patch tokens
(2 x 1280, the embedding of the Virchow model cards; Virchow2's 4 register tokens are left out of the mean), written
to {model}_2560/features. The virchow one is the input of PRISM:
--preset virchow --outputs cls,concat -S 224 writes virchow/features (1280) and virchow_2560/features (2560) in
one pass (Virchow was trained on 224 px tiles at 0.5 mpp).
kaiko-midnight is kaiko.ai's Midnight-12k and outputs the CLS token concatenated with the mean of the patch tokens
(the model card's classification embedding, 2 x 1536), so it has no latent output. midnight is a different model
(SophontAI's OpenMidnight). hibou-b / hibou-l are rebuilt in timm from the transformers weights, so they need
neither trust_remote_code nor network access once the weights are in the HF cache.
| Preset | Tile source | Dim | HuggingFace |
|---|---|---|---|
titan |
conch15 output proj (e.g. conch15_768) |
768 | MahmoodLab/TITAN |
prism |
virchow output concat (e.g. virchow_2560) |
1280 | paige-ai/Prism |
PRISM's slide embedding is its Perceiver resampler's output (image_embedding, no coordinates); only that part is
built (in plain torch, weights from model.safetensors; the repo's remote code does not load under transformers 5),
so it needs no trust_remote_code and runs with HF_HUB_OFFLINE=1 once the weights are cached.
--presetselects which foundation model to load (e.g.uni,gigapath). Onlypresetis a session-level default.-M/--modelis the HDF5 storage key under which results are written. Defaults to--preset. Use a distinct value to keep multiple extractions of the same preset separate (e.g. different--patch-size).
wt extract -i sample.ndpi --preset uni # → uni/features
wt extract -i sample.ndpi --preset uni -M uni_224 -S 224 # → uni_224/features (same preset, 224 px)Setup: These models require HuggingFace authentication. Accept the license on each model page, then:
huggingface-cli loginDevice selection is controlled by --device / -D (CLI) or the device= argument of a command (Python).
set_default_device() sets the process-wide default that is used when device= is not given. Default is auto.
| Value | Behavior |
|---|---|
auto (default) |
Detect all GPUs. Multiple GPUs → parallel inference. Single GPU → cuda:0. No GPU → cpu (with warning) |
cuda:0 |
Use GPU 0 only. Falls back to cpu if unavailable (with warning) |
cuda:1 |
Use GPU 1 only |
cuda:0,1,3 |
Use specified GPUs in parallel |
cpu |
CPU only |
wt extract -i sample.ndpi -D auto # Auto-detect (default)
wt extract -i sample.ndpi -D cuda:0 # Single GPU
wt extract -i sample.ndpi -D cuda:0,1 # 2 GPUs in parallelcmd = wt.FeatureExtractionCommand(model='uni2', preset='uni2', device='cuda:0,1') # Use GPU 0 and 1
wt.set_default_device('cuda:0,1') # or: process-wide default for commands called without device=For the Streamlit app, set via environment variable:
WT_DEVICE=cuda:0 uv run task appTileEncoder holds the loaded tile model(s) and the acceleration choice. Build it once, call warmup(),
and pass it to as many FeatureExtractionCommand calls as you like (a long-lived service pays the compile
cost once per process). Without encoder= the command builds a temporary encoder per call.
enc = wt.TileEncoder('gigapath-flash', device='cuda', accel='graphs')
enc.warmup() # compiles every bucket shape (~5 s each cold, ~1 s with a warm cache)
cmd = wt.FeatureExtractionCommand(model='gigapath-flash', encoder=enc, batch_size=512)
cmd('a.h5', wsi_path='a.ndpi'); cmd('b.h5', wsi_path='b.ndpi')
enc.close()wt extract -i sample.ndpi --preset gigapath-flash --accel graphs # none (default) | compile | graphsaccel |
What it does |
|---|---|
none |
Eager forward on each reader batch (bit-identical to earlier releases) |
compile |
torch.compile(dynamic=False); every batch is padded to a fixed bucket (64/128/256/512 patches) so there is one graph per shape |
graphs |
The same plus CUDA graphs (mode="reduce-overhead"), which removes the kernel-launch overhead of a small model such as GigaPath-Flash (ViT-S) |
Padding repeats the last patch and is sliced off again, so features do not depend on it (cosine to eager
0.9999; the bf16 kernels differ slightly). The padding is wasted compute, so the gain depends on how full the reader's row batches are: exactly filled buckets run 1.5x faster than eager on a GB10, a real slide 1.25-1.3x (
_docs/benchmark-pyramid-dzi.md§12.4). On an RTX 3090 the gain on filled buckets is only 1.2-1.25x, and a real slide atbatch_size=512is 7-15 % slower than eager (§13). Finer buckets (buckets=tuple(range(32, 513, 32))) or a largerbatch_size(more rows per batch) fill them better at the cost of more shapes to compile.accelis meant for long-lived processes: a one-shotwt extract --accel graphspays the warmup inside the run and is slower than eager for a single slide. CUDA only; on the CPUaccelfalls back tononewith a warning. The H5 group attrs andFeatureExtractResult.accelrecord what ran.
Blackwell / GB10 (DGX Spark): the +cu128 torch wheels have no bf16 Tensor Core GEMM for sm_121 (10.7 vs
97 TFLOPS), so pyproject.toml pins the cu130 index for Linux and uv uses its own managed CPython
(python-preference = "only-managed"; torch.compile needs Python.h, which the hosts' system Python lacks).
The cu130 wheels also run on x86_64 / RTX 3090 (driver 615) at the same speed as cu128, with eager features
matching to cosine > 0.99998 (§13).
# 1. Extract features from WSI
wt extract -i sample.ndpi -o sample.h5
# 2. Run clustering
wt cluster -i sample.h5
# 3. Generate preview image (requires sample.ndpi in same directory)
wt preview -i sample.h5import wsi_toolbox as wt
# Process-wide defaults (optional). Commands read them when preset= / device= are not given.
wt.set_default_preset('uni2')
wt.set_default_device('auto')
# 1. Extract (progress goes to a tqdm bar by default; see "Python API" for sinks and cancellation)
cmd = wt.FeatureExtractionCommand(model='uni2', preset='uni2', batch_size=256)
cmd('sample.h5', wsi_path='sample.ndpi')
# 2. Cluster
cluster_cmd = wt.ClusteringCommand(model='uni2', resolution=1.0)
cluster_cmd(['sample.h5'])
# 3. Preview
preview_cmd = wt.PreviewClustersCommand(model='uni2')
img = preview_cmd('sample.h5')
img.save('sample_preview.jpg')Important: preview / preview-score commands require the original WSI file with the same stem in the same directory (e.g., sample.h5 needs sample.ndpi).
Every command follows the same pattern: configuration in __init__, execution in __call__, a Pydantic
result object back. In 0.6 the three things a caller may want to control at run time are all arguments:
| What | Where | Fallback when omitted |
|---|---|---|
| Foundation model | preset= in __init__ (name or TilePreset) |
wt.set_default_preset(...); ValueError if neither is set |
| Device | device= in __init__ |
wt.set_default_device(...) (default auto) |
| Progress display | on_progress= in __call__ (keyword-only) |
wt.set_default_progress(...) (default tqdm) |
| Cancellation | should_cancel= in __call__ (keyword-only) |
never cancelled |
The library never writes the defaults itself; only wt.set_default_* does. Notebooks can set them once, services
should pass everything explicitly. The full list of names is in README_API.md.
Commands do not draw progress bars. They emit wt.ProgressEvent values and a sink — any callable taking one
event — decides what to do with them:
@dataclass(frozen=True)
class ProgressEvent:
phase: str # "Initializing model", "Processing patches", "UMAP", ...
n: int # progress within the phase
total: int | None # phase size, None when unknown
elapsed: float # seconds since the command started
message: str = "" # extra text (batch description etc.)
done: bool = False # True only for the final event
# .fraction -> n / total, or NoneBuilt-in sinks (all importable from wsi_toolbox):
| Sink | Use |
|---|---|
TqdmSink(**tqdm_kwargs) |
One tqdm bar per phase. Default when nothing is given |
RichSink(console=None) |
rich.progress display; what the CLI uses |
StreamlitSink(container=None) |
st.progress per phase; used by the Streamlit app |
LoggingSink(logger=None, every=5.0, level=INFO) |
phase [n/total] message to a logger, at most once per every seconds |
MultiSink(*sinks) |
Fan one stream out to several sinks |
NullSink() |
Silence. on_progress=None does the same |
resolve_sink("tqdm" | "rich" | "streamlit" | "logging" | "none") |
Name → fresh sink instance |
import logging
import wsi_toolbox as wt
cmd = wt.FeatureExtractionCommand(model='uni2', preset='uni2', device='cuda:0')
cmd('sample.h5', wsi_path='sample.ndpi') # TqdmSink (default)
cmd('sample.h5', wsi_path='sample.ndpi', on_progress=wt.RichSink()) # rich
cmd('sample.h5', wsi_path='sample.ndpi', on_progress=wt.NullSink()) # silent (or on_progress=None)
# tqdm on the terminal + throttled lines in a service log
sink = wt.MultiSink(wt.TqdmSink(), wt.LoggingSink(logging.getLogger('myservice'), every=5.0))
cmd('sample.h5', wsi_path='sample.ndpi', on_progress=sink)
# Streamlit: draw inside a container
# cmd('sample.h5', on_progress=wt.StreamlitSink(st.container()))
# Any callable works as a sink
def print_progress(event: wt.ProgressEvent) -> None:
if event.done:
print(f"done in {event.elapsed:.1f}s")
elif event.total:
print(f"{event.phase}: {event.n}/{event.total} {event.message}")
else:
print(f"{event.phase} ...")
cmd('sample.h5', wsi_path='sample.ndpi', on_progress=print_progress)
# Process-wide default for commands called without on_progress= (a name, a callable, or None)
wt.set_default_progress('rich')
wt.set_default_progress(print_progress)
wt.set_default_progress(None) # silentSinks are stateful (they hold the current bar), so create a new one per command call; resolve_sink and the
defaults machinery already do that for you.
Pass should_cancel, a zero-argument callable returning True when the command should stop. It is polled at
every phase boundary and, in the long phases, after every batch / row strip / tile / patch. When it returns
True the command raises wt.Cancelled after cleaning up its partial output (an interrupted extract
leaves no half-written features dataset behind, an interrupted cache removes the partial cache).
import threading
import wsi_toolbox as wt
stop = threading.Event() # set from another thread, a signal handler, a UI button, ...
cmd = wt.FeatureExtractionCommand(model='uni2', preset='uni2', device='cuda:0')
try:
cmd('sample.h5', wsi_path='sample.ndpi', should_cancel=stop.is_set)
except wt.Cancelled as e:
print('cancelled:', e) # "cancelled during 'Processing patches'"should_cancel works with on_progress=None too; the two are independent.
A built-in preset is just a wt.TilePreset looked up by name (wt.get_tile_preset('uni2')). To run your own
encoder, build one yourself and pass it wherever a preset name is accepted — preset= of
FeatureExtractionCommand, or wt.set_default_preset(...):
import wsi_toolbox as wt
def create_my_model():
# Return a fresh torch.nn.Module. Do NOT move it to a device or call .eval(); the command does that.
import timm
return timm.create_model('hf-hub:MahmoodLab/uni', pretrained=True, dynamic_img_size=True, init_values=1e-5)
my_preset = wt.TilePreset(
name='my-uni', # written to {model}/.attrs['preset'] in the HDF5 file
create_model=create_my_model,
norm_mean=(0.485, 0.456, 0.406), # input normalization (RGB, 0-1 scale); ImageNet by default
norm_std=(0.229, 0.224, 0.225),
extract_fn=None, # None: model.forward_features(x)[:, 0] (CLS token)
)
cmd = wt.FeatureExtractionCommand(model='my-uni', preset=my_preset, device='cuda:0')
cmd('sample.h5', wsi_path='sample.ndpi')With extract_fn=None the module must expose forward_features(x) returning (B, 1 + tokens, dim) with the CLS
token first, and patch_embed.proj.kernel_size (used for the latent output). For any other interface give
extract_fn=lambda model, x: ... returning the (B, dim) features; the only output is then cls.
Extra outputs from the same forward pass are heads: heads={'proj': wt.TileHead(key_suffix='_proj', fn=lambda model, tokens: ...)} (tokens = forward_features(x), returns (B, D)), selected with outputs=('cls', 'proj')
and written to {model}_proj/features. Built-in presets are TilePreset subclasses that override
create_model() (e.g. conch15 declares the proj head).
Sinks receive the phase names below, in this order, for each command. A phase with a known total advances step
by step; the others emit a single event at n=0. ClusterWithUmapCommand runs UMAP then clustering through one
reporter, so its stream is the two lists back to back, ending in a single done event.
| Command | Phases (total) |
|---|---|
FeatureExtractionCommand |
Initializing model → Processing patches (batches; message = reader stats) → Writing |
CacheCommand |
Caching patches (row strips; message = reader stats) |
AggregateCommand |
Loading features → Initializing model → Aggregating → Writing |
ClusteringCommand |
Loading features → PCA → KNN → Building graph → Leiden clustering → Finalizing → Sorting clusters (only with sort_clusters=True) → Writing |
UmapCommand |
Loading features → UMAP → Writing |
PCACommand |
Loading features → PCA → Writing |
ClusterWithUmapCommand |
UmapCommand phases, then ClusteringCommand phases |
Preview*Command |
Rendering patches (patches) |
DziCommand |
Generating tiles (tiles; message = Level L: row r/R) |
PyramidCommand |
Building pyramid (n = percent done, total = 100) |
ShowCommand |
none (no on_progress) |
When a command skips its work (output already present and overwrite=False) no phase is emitted, but the final
done=True event still arrives.
The CLI uses the same machinery. --progress selects the sink for every subcommand:
wt extract -i sample.ndpi --progress rich # default
wt extract -i sample.ndpi --progress tqdm
wt extract -i sample.ndpi --progress none # no bars (logs only)CLI is available as wsi-toolbox or wt. Each command has --help. Options shared by all subcommands:
--preset, -M/--model, -D/--device, --progress rich|tqdm|none, --seed, -v.
Extract patch embeddings from WSI using foundation models.
| CLI | Python |
|---|---|
wt extract -i sample.ndpi -o sample.h5 |
FeatureExtractionCommand(model='uni2', preset='uni2')(h5_path, wsi_path=...) |
wt extract -i sample.ndpi -o sample.h5
wt extract -i sample.ndpi --preset gigapath # Use Gigapath
wt extract -i sample.ndpi --preset gigapath-flash # GigaPath-Flash (ViT-S, ~50x cheaper)
wt extract -i sample.ndpi --preset virchow2 # Use Virchow2
wt extract -i sample.ndpi --preset conch15 --outputs cls,proj # CONCH v1.5: 1024D CLS + 768D (TITAN-ready), one pass
wt extract -i sample.ndpi --preset virchow --outputs cls,concat -S 224 # Virchow: 1280D CLS + 2560D (PRISM-ready)
wt extract -i sample.ndpi --preset midnight # OpenMidnight
wt extract -i sample.ndpi --preset kaiko-midnight # Midnight-12k (kaiko.ai), CLS + mean patch tokens
wt extract -i sample.ndpi --outputs cls,latent # Include latent features (patch tokens; -L is the old spelling)
wt extract -i sample.ndpi -D cuda:0,1 # Multi-GPU parallelcmd = wt.FeatureExtractionCommand(model='uni2', preset='uni2', batch_size=256, outputs=('cls', 'latent'))
result = cmd('sample.h5', wsi_path='sample.ndpi')
# result.feature_dim, result.patch_count
cmd = wt.FeatureExtractionCommand(model='conch15', preset='conch15', outputs=('cls', 'proj'))
cmd('sample.h5', wsi_path='sample.ndpi')
# -> conch15/features (N, 1024), conch15_768/features (N, 768)Outputs (--outputs / outputs=, default cls): cls → {model}/features; latent → {model}/latent_features
(only together with cls); a preset head such as conch15's proj → {model}_768/features (virchow's concat →
{model}_2560/features) with its own copy of the coordinates. Presets with a custom feature function
(kaiko-midnight) only have cls. When some of the requested outputs already exist and others do not, the command
stops; -O rewrites all of them.
Run a slide-level aggregator (TITAN, PRISM; --slide-preset, default titan) on tile features to produce a single
slide embedding.
| CLI | Python |
|---|---|
wt aggregate -i sample.h5 |
AggregateCommand(slide_preset='titan', tile_model='conch15_768')('sample.h5') |
wt aggregate -i sample.h5 --slide-preset prism |
AggregateCommand(slide_preset='prism', tile_model='virchow_2560')('sample.h5') |
# Auto-resolve: scans the h5 for tile features compatible with titan (preset conch15, output proj;
# also old files with preset=conch15_768)
wt aggregate -i sample.h5
# Explicit storage key (multiple compatible groups → required)
wt aggregate -i sample.h5 -M conch15_768
# PRISM: scans for preset virchow, output concat (virchow_2560)
wt aggregate -i sample.h5 --slide-preset prismcmd = wt.AggregateCommand(slide_preset='titan', tile_model='conch15_768')
result = cmd('sample.h5')
# → conch15_768/aggregates/titan/feature (D=768)
wt.AggregateCommand(slide_preset='prism', tile_model='virchow_2560')('sample.h5')
# → virchow_2560/aggregates/prism/feature (D=1280)TITAN requires conch15_768/features (run wt extract --preset conch15 --outputs proj -S 512 first), PRISM
virchow_2560/features (wt extract --preset virchow --outputs concat -S 224).
Run Leiden clustering on embeddings.
| CLI | Python |
|---|---|
wt cluster -i sample.h5 |
ClusteringCommand(model='uni2')(['sample.h5']) |
wt cluster -i sample.h5
wt cluster -i sample.h5 --resolution 0.5 # Fewer clusterscmd = wt.ClusteringCommand(model='uni2', resolution=1.0)
result = cmd(['sample.h5'])
# result.cluster_count, result.target_pathSee Advanced Usage for multi-file clustering and sub-clustering.
Generate cluster overlay image. Requires WSI with same stem.
| CLI | Python |
|---|---|
wt preview -i sample.h5 |
PreviewClustersCommand(model='uni2')('sample.h5') |
wt preview -i sample.h5
wt preview -i sample.h5 -f 1 2 3 # Filter to clusters 1,2,3
wt preview -i sample.h5 --size 32 # Smaller thumbnailscmd = wt.PreviewClustersCommand(model='uni2', size=64)
img = cmd('sample.h5', namespace='default')
img.save('preview.jpg')Compute UMAP projection.
| CLI | Python |
|---|---|
wt umap -i sample.h5 |
UmapCommand(model='uni2')(['sample.h5']) |
wt umap -i sample.h5
wt umap -i sample.h5 --show # Display plot
wt umap -i sample.h5 --save # Save plotcmd = wt.UmapCommand(model='uni2', n_neighbors=15, min_dist=0.1)
result = cmd(['sample.h5'])
# result.target_path → 'uni2/default/umap'Compute PCA projection.
| CLI | Python |
|---|---|
wt pca -i sample.h5 |
PCACommand(model='uni2')(['sample.h5']) |
wt pca -i sample.h5
wt pca -i sample.h5 -n 2 # 2 components
wt pca -i sample.h5 --show # Display plotcmd = wt.PCACommand(model='uni2', n_components=1, scaler='minmax')
result = cmd(['sample.h5'])
# result.target_path → 'uni2/default/pca1'Generate score heatmap overlay. Requires WSI with same stem.
| CLI | Python |
|---|---|
wt preview-score -i sample.h5 -n pca1 |
PreviewScoresCommand(model='uni2')('sample.h5', score_name='pca1') |
wt preview-score -i sample.h5 -n pca1
wt preview-score -i sample.h5 -n pca1 --cmap viridis
wt preview-score -i sample.h5 -n pca1 --invertcmd = wt.PreviewScoresCommand(model='uni2', size=64)
img = cmd('sample.h5', score_name='pca1', cmap_name='jet')
img.save('pca_heatmap.jpg')Display HDF5 file structure.
| CLI | Python |
|---|---|
wt show -i sample.h5 |
ShowCommand()('sample.h5') |
wt show -i sample.h5
wt show -i sample.h5 -v # VerboseGenerate thumbnail from WSI.
| CLI | Python |
|---|---|
wt thumb -i sample.ndpi |
wsi.generate_thumbnail() |
wt thumb -i sample.ndpi
wt thumb -i sample.ndpi -w 1024 # Specify widthExport WSI to Deep Zoom Image format (for OpenSeadragon).
| CLI | Python |
|---|---|
wt dzi -i sample.ndpi -o ./out |
DziCommand()(wsi_path, output_dir, name) |
wt dzi -i sample.ndpi -o ./output
wt dzi -i sample.ndpi -o ./output -t 512 # Tile sizeTo serve DZI tiles on demand instead of writing them all, use wsi_toolbox.dzi (see below).
Convert a WSI into a tiled pyramidal TIFF optimised for DZI serving: every 2x level present, 512 px JPEG (Q85) tiles, BigTIFF. A 256 px DZI tile is then one contiguous read, which is much faster than reading an NDPI / SVS original, above all on HDD / NFS. Built in-process and streaming (openslide reads level 0 in bands, each level is the 2x2 mean of the one above, imagecodecs encodes the tiles in threads, tifffile writes the pages), so memory stays at a few bands of rows and no system library is needed.
| CLI | Python |
|---|---|
wt pyramid -i sample.ndpi |
PyramidCommand()(wsi_path, output_path) |
wt pyramid -i sample.ndpi # -> sample.pyramid.tif
wt pyramid -i sample.ndpi -o out.tif -q 90 -t 256 # JPEG quality / tile sizeThe output is written to .<name>.tmp and renamed into place, so a failed or cancelled run leaves nothing
behind. create_wsi_file("sample.pyramid.tif") opens it like any pyramidal TIFF.
Benchmark against the originals (NDPI / SVS / TIFF / MIRAX on SSD / HDD / NFS) and how to rerun it on your
slides: _docs/benchmark-pyramid-dzi.md (scripts/bench_pyramid.py).
DziGenerator answers .dzi and tile requests for any WSI toolbox opens (a pyramid.tif is fastest):
from wsi_toolbox import DziGenerator, DziTileNotFound, create_wsi_file, encode_tile
wsi = create_wsi_file("sample.pyramid.tif") # one WSI object per thread
dzi = DziGenerator(wsi, tile_size=256, overlap=0)
xml = dzi.xml() # {name}.dzi
try:
tile = dzi.tile(level, col, row) # RGB uint8, exactly the spec's tile size
except DziTileNotFound:
... # 404
jpeg = encode_tile(tile, quality=90) # {name}_files/{level}/{col}_{row}.jpegTiles always have the size the Deep Zoom spec gives, even when native levels are rounded (SVS 4.0001, odd sizes). Levels that exist natively are copied without resampling; the others are resampled from the next finer level with Lanczos over the exact source box.
Pre-cache patch images for repeated access:
wt cache -i sample.ndpi -o sample.h5
wt extract -i sample.h5 # Uses cache
wt preview -i sample.h5 # Uses cacheStructure:
cache/{patch_size}/
├── patches # [N, H, W, 3] images
└── coordinates # [N, 2] coords
Migrate old HDF5 format to new format.
wt migrate -i sample.h5
wt migrate -i sample1.h5 sample2.h5 # Multiple filesAll data is stored in a single HDF5 file. Use wt show -i sample.h5 to inspect.
with h5py.File('sample.h5', 'r') as f:
# WSI metadata
f.attrs['original_mpp'] # Original microns per pixel
f.attrs['original_width'] # Original width (px)
f.attrs['original_height'] # Original height (px)
# Default extraction grid (legacy/back-compat; per-preset values live on {model}/.attrs)
f.attrs['mpp']
f.attrs['patch_count']
f.attrs['cols']
f.attrs['rows']Features are stored under {model}/. model (the storage key) defaults to the preset name (e.g. uni) but is a free string when -M is given. A head output goes to {model} plus the head's suffix (conch15 proj → conch15_768/, virchow concat → virchow_2560/), with the same attrs and its own coordinates.
{model}/ attrs: preset, output, patch_size, target_mpp, mpp, cols, rows, patch_count, accel
├── features # [N, D]
├── coordinates # [N, 2] level-0 (x, y) in pixels
├── latent_features # [N, L, D] optional (--outputs cls,latent)
├── aggregates/ # slide-level aggregator outputs
│ └── {slide_preset}/
│ └── feature # [D_slide]
└── {namespace}/ # analysis results (see below)
Feature dim per tile preset: uni: 1024, uni2: 1536, gigapath: 1536, gigapath-flash: 384, virchow/2: 1280 (concat → virchow_2560 / virchow2_2560: 2560), h-optimus-0: 1536, conch15: 1024 (proj → conch15_768: 768), midnight: 1536, phikon2: 1024, hibou-b: 768, hibou-l: 1024, h0-mini: 768, phikon: 768, kaiko-midnight: 3072.
with h5py.File('sample.h5', 'r') as f:
features = f['uni/features'][:] # (N, 1024)
coords = f['uni/coordinates'][:] # (N, 2)
preset = f['uni'].attrs['preset'] # which foundation model
output = f['uni'].attrs.get('output', 'cls') # 'cls' / 'proj' / 'concat' (absent in files before outputs=)
slide = f['conch15_768/aggregates/titan/feature'][:] # (768,)
slide = f['virchow_2560/aggregates/prism/feature'][:] # (1280,)Results are stored under {model}/{namespace}/.
{model}/{namespace}/
├── clusters # [N] cluster labels (int)
├── umap # [N, 2] UMAP coordinates
└── pca1 # [N] PCA scores
Namespace:
- Single file:
default - Multi-file:
file1+file2+...(auto-generated)
Sub-clustering (filter hierarchy):
{model}/default/clusters # Base
{model}/default/filter/1+2+3/clusters # Sub-cluster of 1,2,3
{model}/default/filter/1+2+3/filter/0+1/clusters # Further nesting
See Advanced Usage for examples.
Large datasets have a writing attribute (True during write, False when complete).
if f['uni/features'].attrs.get('writing', False):
raise RuntimeError('Dataset is incomplete')Cluster multiple WSIs together to find common patterns across samples.
# 1. Extract features from each WSI
wt extract -i sample1.ndpi -o sample1.h5
wt extract -i sample2.ndpi -o sample2.h5
# 2. Joint clustering (namespace auto-generated as "sample1+sample2")
wt cluster -i sample1.h5 sample2.h5
# 3. Analysis on joint clusters
wt pca -i sample1.h5 sample2.h5
wt umap -i sample1.h5 sample2.h5
# 4. Preview each file (uses shared cluster labels)
wt preview -i sample1.h5 -N sample1+sample2
wt preview -i sample2.h5 -N sample1+sample2# Joint clustering
cmd = wt.ClusteringCommand(model='uni2')
result = cmd(['sample1.h5', 'sample2.h5'])
# → namespace: 'sample1+sample2'
# → uni2/sample1+sample2/clusters in both filesAnalyze a subset of clusters in more detail.
# Sub-cluster within clusters 1,2,3
wt cluster -i sample1.h5 sample2.h5 -f 1 2 3
# PCA/UMAP on filtered subset
wt pca -i sample1.h5 sample2.h5 -f 1 2 3
wt umap -i sample1.h5 sample2.h5 -f 1 2 3
# Preview filtered clusters
wt preview -i sample1.h5 -N sample1+sample2 -f 1 2 3# Sub-cluster
cmd = wt.ClusteringCommand(model='uni2', parent_filters=[[1, 2, 3]])
cmd(['sample1.h5', 'sample2.h5'])
# → uni2/sample1+sample2/filter/1+2+3/clusters
# PCA on filtered subset
cmd = wt.PCACommand(model='uni2', parent_filters=[[1, 2, 3]])
cmd(['sample1.h5', 'sample2.h5'])
# → uni2/sample1+sample2/filter/1+2+3/pca1uv run task app
# Environment variables
WT_PRESET=gigapath WT_DEVICE=cuda:1 WT_PREFETCH=2 uv run task appgit clone https://github.com/technoplasm/wsi-toolbox.git
cd wsi-toolbox
uv sync
uv run wt --help
uv run task app
uv run task test # pytest; CPU only, no model downloads
uv run task lintMIT