Slide inference and merging#
Predict slide tiles, then combine their nucleus polygons into one result. See the whole-slide guide for the full workflow and plots.
Predict and merge tiles#
import albumentations as A
import geopandas as gpd
import torch
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.albu_transforms import (
MinMaxNormalization,
)
from cellseg_models_pytorch.wsi import SlideReader
reader = SlideReader("data/slide.svs", backend="OPENSLIDE")
tiles = reader.get_tile_coordinates(
width=256,
tissue_mask=None,
overlap=0.25,
out_of_bounds=False,
)
model = CellPose.from_pretrained(
"checkpoints/cellpose.safetensors", device=torch.device("cpu")
)
model.set_inference_mode(mixed_precision=False)
model.post_processor = PostProcessor("cellpose", {"use_gpu": False})
segmenter = WsiSegmenter(
reader,
model,
level=0,
coordinates=tiles.coordinates,
batch_size=1,
normalization=A.Compose([MinMaxNormalization(copy=True)]),
)
segmenter.segment("outputs/slide-tiles")
segmenter.merge_instances(
"outputs/slide-tiles", "outputs/slide-nuclei.parquet", simplify_level=0
)
nuclei = gpd.read_parquet("outputs/slide-nuclei.parquet")
Use your checkpoint’s normalization. Choose new output paths for each run. Empty tiles produce no file; saved filenames identify the source tile for merging. Saved polygons always use full-resolution (level-zero) slide coordinates, even when reading a lower-resolution level. Choose a level that matches your checkpoint’s training resolution.
nuclei contains one row per object, with its polygon, class and slide-wide uid.
Coordinates remain in pixels. See metrics to evaluate masks and the
WSI guide to plot saved polygons.
Prediction reads images in batches. Merging loads the saved polygon tables into memory, so memory use grows with the number and complexity of objects.
- class WsiSegmenter(reader, model, level, coordinates, batch_size=8, normalization=None, *, read_device=None)#
Predict slide tiles and merge their saved nucleus polygons.
Set up tiled nucleus prediction for a slide.
- Parameters:
reader (SlideReader) – SlideReader used to read the tiles.
model (BaseModelInst) – Model with set_inference_mode already called. Use the normalization and class mapping supplied with its checkpoint.
level (int) – Slide level to read; 0 gives full resolution. Saved polygons always use level-zero slide coordinates.
coordinates (list[tuple[int, int, int, int]]) – List of level-zero (x, y, width, height) bounds. Pass TileCoordinates.coordinates when using reader-generated tiles. Read tiles in a batch must have matching image sizes.
batch_size (int) – Number of tiles predicted together.
normalization (Callable[[...], dict[str, ndarray | Tensor]] | None) – Callable receiving image=tile and returning a dictionary with an HWC image. Keep its size and pixel positions unchanged. For CUDA reads, accept and return a tensor.
read_device (str | device | None) – None reads CPU tiles. A CUDA device requires CUCIM and keeps tiles on GPU through normalization. Reconstruction and polygon saving still use CPU data.
- merge_instances(src, dst, clear_in_dir=False, simplify_level=0.3, precision=None, *, filter_level=0, pixel_size=None)#
Merge this run’s tile files into one slide-wide nucleus file.
- Parameters:
src (str | Path) – Folder containing this run’s nucleus .parquet files. Each filename must identify one of the original source tiles.
dst (str | Path) – New .parquet, .geojson or .feather file. Parent folders are created.
clear_in_dir (bool) – Delete every file in src and remove the folder after merging, including unrelated files. Leave False to retain tile outputs.
simplify_level (float) – Outline simplification tolerance in pixels; 0 disables it.
precision (int | None) – Decimal places for coordinate rounding, or None.
filter_level (int | None) – Small/thin-object filter after simplification and rounding. 0 or None disables it. See filter_nuclei for levels and limits.
pixel_size (float | None) – Micrometers per level-zero pixel, required when filtering. Pixels must have equal physical height and width.
- Returns:
None. Writes polygons and their selected source attributes, with new slide-wide uid values. See InstMerger.merge for merging behavior.
- Raises:
ValueError – segment has not finished, a file has no matching source tile, or output/filter settings are invalid.
FileNotFoundError – The source folder does not exist.
FileExistsError – The destination already exists.
- Return type:
None
Note
Merging loads the saved polygon tables into memory. Memory use grows with the number and complexity of polygons, even though reading and prediction use batches.
- segment(save_dir, maptype='amap')#
Predict the selected tiles and save nucleus polygons.
- Parameters:
save_dir (str | Path) – Tile-output folder; created if needed. Filenames record the slide name and source bounds. Choose a new folder for each run.
maptype (str) – Only “amap” (GeoParquet) is supported.
- Raises:
ValueError – Map type is unsupported or source bounds produce duplicate names.
FileExistsError – A tile output already exists.
- Return type:
None
Note
Tiles are processed in batches as float32 tensors on the model device. Uint8 images are divided by 255; other values are kept as supplied. Saved polygons use the reader’s level-zero pixel scale along each axis, including when the model returns smaller or larger prediction maps. Empty tiles produce no file. Failed runs keep completed tile files; merging is enabled only after every batch succeeds.
Merge polygons directly#
Use InstMerger when you already have a GeoDataFrame of predictions. Keep each
polygon’s source-tile index, especially for overlapping tiles.
This small example describes the same nucleus in two tiles, plus a separate cell:
import geopandas as gpd
from shapely.geometry import box
from cellseg_models_pytorch.wsi.inst_merger import InstMerger, filter_nuclei
bounds = [(0, 0, 64, 64), (32, 0, 64, 64)]
predictions = gpd.GeoDataFrame(
{"class_name": ["tumor", "tumor", "immune"]},
geometry=[box(40, 20, 48, 28), box(40, 20, 48, 28), box(80, 20, 88, 28)],
)
merger = InstMerger(predictions, bounds, tile_indices=[0, 1, 1])
merged = merger.merge(simplify_level=0)
merged has two objects. Duplicate predictions keep a whole contour from one
tile; unmatched cells remain. Touching fragments can join across a shared tile
edge. Other attributes come from the selected source, or the largest joined fragment.
Pass dst to save instead of returning a table. Supported formats are .parquet,
.geojson and .feather; existing files are not overwritten.
- class InstMerger(gdf, coordinates, tile_indices=None)#
Combine tiled nucleus polygons into one slide-wide result.
Duplicate predictions keep a whole contour from one source tile. Unmatched nuclei are retained. Touching fragments can join across a shared tile edge.
Set up merging for polygons from known source tiles.
- Parameters:
gdf (gpd.GeoDataFrame) – Nucleus polygons in level-zero slide pixels. Keep class_name and any other source attributes. Optional is_partial must be boolean, with no missing values, and marks objects cut off by tile edges.
coordinates (list[tuple[int, int, int, int]]) – Source-tile bounds as (x, y, width, height) in the same level-zero pixel coordinates as the polygons.
tile_indices (Sequence[int] | None) – One index into coordinates per input row. Required for overlapping tiles. Without overlap, the source can be inferred from each polygon’s position.
- Raises:
ValueError – Source indices are missing or invalid, or is_partial is not boolean.
- merge(dst=None, simplify_level=1, precision=None, *, filter_level=0, pixel_size=None)#
Merge polygons, then optionally simplify, filter and save them.
Duplicates retain one source contour and its attributes. Joined fragments keep the largest fragment’s attributes. Unmatched nuclei remain in the result. Fragments must share a boundary to join; a corner touch is not enough.
- Parameters:
dst (str | Path | None) – New .parquet, .geojson or .feather file. None returns the table. Parent folders are created; an existing file is never overwritten.
simplify_level (float) – Outline simplification tolerance in pixels; 0 keeps the original outlines before any joining or rounding.
precision (int | None) – Decimal places for coordinate rounding, or None to keep them.
filter_level (int | None) – Optional small/thin-object filter, after simplification and rounding. 0 or None disables it; see filter_nuclei for limits.
pixel_size (float | None) – Micrometers per coordinate pixel when filtering. Pixels must have equal physical height and width.
- Returns:
A GeoDataFrame, or None when saved. Input attributes and CRS are kept; uid is replaced with unique slide-wide IDs and also used as the index. Filtering does not renumber surviving IDs. Coordinates stay in pixels.
- Raises:
ValueError – Output format or enabled filter settings are invalid.
FileExistsError – The destination already exists.
- Return type:
GeoDataFrame | None
Optional filtering#
Filtering is off by default. It removes objects only when they are both small and thin; valid small nuclei can also be removed. Supply a measured pixel size, with equal physical height and width. The limits are listed below.
For the example polygons, assuming 0.25 micrometers per pixel:
filtered = filter_nuclei(merged, pixel_size=0.25, filter_level=1)
Filtering keeps surviving outlines and IDs. To filter while merging, pass
filter_level and pixel_size to either merge method.
- filter_nuclei(gdf, pixel_size=None, filter_level=0)#
Remove polygons that are both small and thin.
An object is removed only when its area and thickness are both below the selected limits. Thickness is 2 * area / perimeter, not the nucleus diameter. Levels 1, 2 and 3 use (6 µm², 0.8 µm), (12 µm², 1.2 µm) and (20 µm², 1.6 µm), respectively. Small valid nuclei can also be removed.
- Parameters:
gdf (GeoDataFrame) – Valid nucleus polygons and any class or metadata columns.
pixel_size (float | None) – Positive finite micrometers per coordinate pixel. Required when enabled; pixels must have equal physical height and width.
filter_level (int | None) – 0 or None disables filtering. Use 1 for the mildest filter, 2 for the middle setting or 3 for the strongest.
- Returns:
Retained rows in their original order, with outlines, IDs, index, attributes and CRS unchanged. Returns the input itself when disabled; does not modify it.
- Raises:
ValueError – An enabled filter lacks valid pixel size, or the level is unknown.
- Return type:
GeoDataFrame
Example
>>> nuclei = gpd.GeoDataFrame(geometry=[box(0, 0, 4, 80), box(10, 0, 14, 40)]) >>> len(filter_nuclei(nuclei, pixel_size=0.25, filter_level=2)) 1