Segment a whole slide#

Segment nuclei across the TCGA-AC-A6IW breast slide using the PanopTILs model from training. This patient is in the tutorial’s validation split.

Select tissue tiles, save nucleus polygons, merge fragments at tile boundaries, and inspect the results at slide and nucleus scale. The output contains nuclei and cell types, not tissue-region segmentation.

The slide is an open-access TCGA image hosted by the NCI Genomic Data Commons (GDC). Please acknowledge TCGA and GDC when using it. The download is about 123 MB.

Setup#

This example uses cuCIM on Linux with an NVIDIA GPU. For CUDA 13, install:

python -m pip install cucim-cu13 "nvidia-nvimgcodec-cu13[nvjpeg2k,nvtiff]"

Use the corresponding cu12 packages with CUDA 12. The cell below enables cuCIM’s GPU reader and loads the codecs for this JPEG2000-compressed slide. Run it before importing the library in a fresh kernel.

Tiles and normalization stay on the GPU through model input. Instance reconstruction and polygon export use the CPU.

OpenSlide is also supported. Install openslide-python openslide-bin, skip the cuCIM initialization cell, and set backend="OPENSLIDE" and read_device=None. JPEG2000 decoders can produce slightly different nucleus boundaries and counts.

import os

# Choose cuCIM's GPU reader before it is imported.
os.environ["ENABLE_CUSLIDE2"] = "1"

# Make the installed decoder libraries available to cuCIM.
from nvidia import nvimgcodec
import json
from pathlib import Path

import geopandas as gpd
import matplotlib.pyplot as plt
import numpy as np
import polars as pl
import requests
import torch
from matplotlib.patches import Patch
from safetensors import safe_open
from shapely.geometry import box

from cellseg_models_pytorch.inference.post_processor import PostProcessor
from cellseg_models_pytorch.inference.wsi_segmenter import WsiSegmenter
from cellseg_models_pytorch.models.cellpose import CellPose
from cellseg_models_pytorch.transforms.functional.normalization import minmax_normalize
from cellseg_models_pytorch.wsi import SlideReader

torch.set_num_threads(2)
device = torch.device("cuda")
slide_path = Path("../../data/wsi/TCGA-AC-A6IW.svs")
checkpoint_path = Path("training-checkpoints/epoch_05/model.safetensors")
output_dir = Path("wsi-results")
tile_size = 1024
filter_level = 2
backend = "CUCIM"
read_device = "cuda"
print(f"Device: {device}")
Device: cuda

Download the slide#

Reuse the downloaded slide on later runs. A temporary file keeps an interrupted download separate from the complete slide.

slide_path.parent.mkdir(parents=True, exist_ok=True)
slide_url = "https://api.gdc.cancer.gov/data/24c1a3c2-cef7-48a3-9d76-3ded34894107"

if not slide_path.exists():
    download_path = slide_path.with_suffix(".part")
    with requests.get(slide_url, stream=True, timeout=120) as response:
        response.raise_for_status()
        with download_path.open("xb") as destination:
            for chunk in response.iter_content(chunk_size=1024 * 1024):
                destination.write(chunk)
    download_path.rename(slide_path)

Read the slide#

Level 0 is approximately 0.252 µm/pixel, close to the training images’ 0.25 µm/pixel. Keep this native resolution for prediction. Read a lower-resolution slide level for the tissue mask and overview.

reader = SlideReader(slide_path, backend=backend)
slide_height, slide_width = reader.dimensions
thumbnail_level = reader.level_from_max_dimension(2048)
thumbnail = reader.read_level(thumbnail_level)

# The polygon filter uses one physical scale, so this example needs square pixels.
pixel_height, pixel_size = reader.pixel_size
if not np.isclose(pixel_height, pixel_size):
    raise ValueError("This filtering example requires square pixels.")

print(f"Slide: {slide_width:,} × {slide_height:,} pixels")
print(f"Resolution: {pixel_size:.3f} µm/pixel")
Slide: 28,777 × 34,620 pixels
Resolution: 0.252 µm/pixel

Select tissue tiles#

Inspect the mask before segmentation, especially around pale or fatty tissue. Use 25% overlap so neighboring predictions include nuclei beyond each tile edge. Keep all selected tiles so separate tissue fragments are included. The tile grid uses level-zero pixel coordinates.

_, tissue_mask = reader.get_tissue_mask(level=thumbnail_level)
coordinates = reader.get_tile_coordinates(
    width=tile_size,
    tissue_mask=tissue_mask,
    max_background=0.95,
    overlap=0.25,
)

# Draw the selected full-resolution tiles on the small thumbnail.
annotated = reader.get_annotated_thumbnail(thumbnail, coordinates, linewidth=3)
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
axes[0].imshow(thumbnail)
axes[0].set_title("Whole slide")

axes[1].imshow(tissue_mask, cmap="gray")
axes[1].set_title("Tissue mask")

axes[2].imshow(annotated)
axes[2].set_title("Selected tiles")
for axis in axes:
    axis.set(xticks=[], yticks=[])
fig.tight_layout()
plt.show()

print(f"Selected tiles: {len(coordinates)}")
../_images/1831f6f61fb26fe791e17518a0fe310cb51dc918ff171f5d297ad9afc6db543d.png
Selected tiles: 804

Load the model#

Use the saved PanopTILs weights and the same min-max normalization as training.

with safe_open(checkpoint_path, framework="pt", device="cpu") as weights:
    class_names = json.loads(weights.metadata()["class_names"])

model = CellPose.from_pretrained(checkpoint_path, device=device)
model.set_inference_mode(mixed_precision=False)
model.post_processor = PostProcessor("cellpose", {"use_gpu": device.type == "cuda"})


def normalization(*, image):
    # Use the training normalization on either NumPy images or CUDA tensors.
    return {"image": minmax_normalize(image)}

Segment the tissue#

Choose a new output_dir for each run. Each batch is processed and saved as tile-level GeoParquet, so the full-resolution slide is never loaded into memory.

output_dir.mkdir(exist_ok=False)
segmenter = WsiSegmenter(
    reader=reader,
    model=model,
    level=0,
    coordinates=coordinates.coordinates,
    batch_size=4,
    normalization=normalization,
    read_device=read_device,
)
segmenter.segment(output_dir / "tiles")
100%|██████████| 201/201 [07:31<00:00,  2.24s/batch]

Merge the tile predictions#

Keep whole nuclei from the tile with the clearest view and retain unmatched detections from other tiles. The merged polygons and their unique IDs use level-zero pixel coordinates.

This example also removes small, thin fragments with filter_level=2. A polygon is removed only when its area is below 12 µm² and its estimated thickness (2 × area / perimeter) is below 1.2 µm. The slide’s pixel_size converts pixel measurements to micrometers.

Set filter_level=0 or None to disable filtering, which is the library default. Level 1 is more conservative; level 3 removes more objects. Small valid nuclei can also be removed, so inspect the result, especially around thin stromal nuclei.

merged_path = output_dir / "nuclei.parquet"
segmenter.merge_instances(
    src=output_dir / "tiles",
    dst=merged_path,
    simplify_level=0.1,
    filter_level=filter_level,
    pixel_size=pixel_size,
)
nuclei = gpd.read_parquet(merged_path)

# Class 0 inside a predicted nucleus is distinct from empty background.
class_labels = ["untyped (class 0)", *class_names[1:]]
counts = pl.DataFrame(
    {
        "Cell type": class_labels,
        "Nuclei": np.bincount(
            nuclei["class_name"].to_numpy(), minlength=len(class_labels)
        ),
    }
)
print(f"Merged nuclei: {len(nuclei):,}")
counts
Merged nuclei: 173,823
shape: (7, 2)
Cell typeNuclei
stri64
"untyped (class 0)"0
"neoplastic"131941
"stromal"21655
"inflammatory"15011
"epithelial"0
"other"0
"unknown"5216

Inspect the whole-slide result#

Use the same colours in the slide overview and detailed overlays. Inspect tissue edges as well as dense cell regions for false positives. Cell-type predictions are model outputs; this slide does not have complete nucleus labels for a whole-slide accuracy score.

class_palette = np.array(
    [
        [0.55, 0.3, 0.15],
        [0.9, 0.2, 0.2],
        [0.2, 0.5, 0.9],
        [0.2, 0.75, 0.3],
        [0.8, 0.3, 0.8],
        [0.95, 0.7, 0.15],
        [0.6, 0.6, 0.6],
    ]
)
colours = class_palette[nuclei["class_name"].to_numpy()]

fig, axes = plt.subplots(1, 2, figsize=(14, 7))
axes[0].imshow(thumbnail, extent=(0, slide_width, slide_height, 0))
axes[0].set_title("Whole slide")

nuclei.plot(ax=axes[1], color=colours, linewidth=0)
axes[1].set_title("Predicted nuclei")
for axis in axes:
    # Image coordinates increase downward on the slide.
    axis.set(xlim=(0, slide_width), ylim=(slide_height, 0), xticks=[], yticks=[])
fig.legend(
    [Patch(color=colour) for colour in class_palette],
    class_labels,
    loc="lower center",
    ncol=4,
)
fig.tight_layout(rect=(0, 0.08, 1, 1))
plt.show()
../_images/e501ccc055ab3e5d2254d385c6df1bd7942029c8eac717ed1ddc548ce5ad4501.png

Inspect nuclei around tile edges#

Inspect twelve regions across the specimen, including horizontal edges, vertical edges and tile junctions. Dashed lines mark input tile edges. Overlapping tiles provide another view of nuclei cut at these edges.

def plot_region(axis, bounds, title):
    x, y, width, height = bounds
    image = reader.read_region(bounds, level=0)
    axis.imshow(image, extent=(x, x + width, y + height, y))

    # Query only the polygons visible in this crop.
    indices = nuclei.sindex.query(
        box(x, y, x + width, y + height), predicate="intersects"
    )
    visible = nuclei.iloc[indices]
    visible.boundary.plot(
        ax=axis, color=class_palette[visible["class_name"].to_numpy()], linewidth=0.8
    )
    # Mark input tile edges, where a neighboring tile provides another view.
    for edge in x_seams:
        if x < edge < x + width:
            axis.axvline(edge, color="yellow", linestyle="--", linewidth=0.8)
    for edge in y_seams:
        if y < edge < y + height:
            axis.axhline(edge, color="yellow", linestyle="--", linewidth=0.8)

    axis.set(
        xlim=(x, x + width), ylim=(y + height, y), xticks=[], yticks=[], title=title
    )


x_seams = sorted({edge for x, y, w, h in coordinates for edge in (x, x + w)})
y_seams = sorted({edge for x, y, w, h in coordinates for edge in (y, y + h)})
seam_centres = [
    (12256, 16384),
    (16384, 16384),
    (9216, 12288),
    (15360, 12288),
    (21504, 12288),
    (9216, 18432),
    (15360, 18432),
    (21504, 18432),
    (9216, 24576),
    (12288, 24576),
    (12288, 21504),
    (12288, 27648),
]

# Keep each crop at native resolution and use the same field of view.
for start in (0, 6):
    fig, axes = plt.subplots(2, 3, figsize=(15, 10))
    for index, (axis, (x, y)) in enumerate(
        zip(axes.flat, seam_centres[start : start + 6]), start=start + 1
    ):
        plot_region(axis, (x - 256, y - 256, 512, 512), f"Region {index}: ({x}, {y})")
    fig.tight_layout()
    plt.show()
../_images/4d005afa001ed456a974918e0186e6378b2897164024efa52759e56ef9eb58e8.png ../_images/6d77e8edd28e893cc63fc456948c2cd70ce20d04f1f32da48ec6d978b3c695d6.png

The merged result is saved in wsi-results/nuclei.parquet. Keep the tile files while inspecting the merge; they preserve the predictions before stitching.