diff --git a/docs/assets/examples/distributed-tiling-pipeline.png b/docs/assets/examples/distributed-tiling-pipeline.png new file mode 100644 index 0000000..c0a8234 Binary files /dev/null and b/docs/assets/examples/distributed-tiling-pipeline.png differ diff --git a/docs/assets/examples/ecdp-exported-tiles.png b/docs/assets/examples/ecdp-exported-tiles.png new file mode 100644 index 0000000..cea9d02 Binary files /dev/null and b/docs/assets/examples/ecdp-exported-tiles.png differ diff --git a/docs/assets/examples/ecdp-inspect-preview.png b/docs/assets/examples/ecdp-inspect-preview.png new file mode 100644 index 0000000..f8b9a63 Binary files /dev/null and b/docs/assets/examples/ecdp-inspect-preview.png differ diff --git a/docs/assets/examples/ecdp-stain-augmentation-triptych.png b/docs/assets/examples/ecdp-stain-augmentation-triptych.png new file mode 100644 index 0000000..2c90cad Binary files /dev/null and b/docs/assets/examples/ecdp-stain-augmentation-triptych.png differ diff --git a/docs/assets/examples/ecdp-tissue-mask-triptych.png b/docs/assets/examples/ecdp-tissue-mask-triptych.png new file mode 100644 index 0000000..71d5e45 Binary files /dev/null and b/docs/assets/examples/ecdp-tissue-mask-triptych.png differ diff --git a/docs/assets/examples/leakage-safe-splits.png b/docs/assets/examples/leakage-safe-splits.png new file mode 100644 index 0000000..3197b3f Binary files /dev/null and b/docs/assets/examples/leakage-safe-splits.png differ diff --git a/docs/assets/examples/pyramid-level-selection.png b/docs/assets/examples/pyramid-level-selection.png new file mode 100644 index 0000000..895e198 Binary files /dev/null and b/docs/assets/examples/pyramid-level-selection.png differ diff --git a/docs/assets/examples/slide-row-to-tile-rows.png b/docs/assets/examples/slide-row-to-tile-rows.png new file mode 100644 index 0000000..458ddee Binary files /dev/null and b/docs/assets/examples/slide-row-to-tile-rows.png differ diff --git a/docs/assets/examples/tile-grid-boundary-strategies.png b/docs/assets/examples/tile-grid-boundary-strategies.png new file mode 100644 index 0000000..cca5eae Binary files /dev/null and b/docs/assets/examples/tile-grid-boundary-strategies.png differ diff --git a/docs/examples/basic-functionalities/annotation-and-overlay-enrichment.md b/docs/examples/basic-functionalities/annotation-and-overlay-enrichment.md new file mode 100644 index 0000000..4193f46 --- /dev/null +++ b/docs/examples/basic-functionalities/annotation-and-overlay-enrichment.md @@ -0,0 +1,218 @@ +# Add Annotation And Overlay Signals + +!!! abstract "Overview" + **Problem solved:** attach annotation-derived geometry or overlay-derived coverage to tile metadata so you can filter or score tiles without moving full-resolution data through every step of the pipeline. + + **Use this example when:** + + - you have polygon annotations in ASAP, GeoJSON, or Darwin JSON format, + - you have an aligned overlay such as a tissue mask or heatmap, + - and you want tile-level metadata for downstream selection or supervision. + +## Option 1: Add Annotation Coverage + +Use `tile_annotations` when your source of truth is geometry. +This is a good fit for expert polygons, region-of-interest annotations, and class-specific boundaries. + +```python +from typing import Any + +import numpy as np +from shapely import Polygon + +from ratiopath.parsers import ASAPParser +from ratiopath.tiling import grid_tiles, tile_annotations + + +def tiles_with_annotation_coverage(row: dict[str, Any]) -> list[dict[str, Any]]: + parser = ASAPParser(row["annotation_path"]) + annotations = list(parser.get_polygons(name="Tumor.*")) + + roi = Polygon( + [ + (0, 0), + (row["tile_extent_x"], 0), + (row["tile_extent_x"], row["tile_extent_y"]), + (0, row["tile_extent_y"]), + ] + ) + + coordinates = np.array( + list( + grid_tiles( + slide_extent=(row["extent_x"], row["extent_y"]), + tile_extent=(row["tile_extent_x"], row["tile_extent_y"]), + stride=(row["stride_x"], row["stride_y"]), + last="keep", + ) + ) + ) + + return [ + { + "path": row["path"], + "tile_x": coordinates[i, 0], + "tile_y": coordinates[i, 1], + "annotation_coverage": geometry.area / roi.area, + } + for i, geometry in enumerate( + tile_annotations( + annotations=annotations, + roi=roi, + coordinates=coordinates, + downsample=row["downsample"], + ) + ) + ] +``` + +??? info "Under the hood for annotation coverage" + The parser converts file-format-specific annotations into Shapely geometries. + `tile_annotations` then builds an `STRtree`, which is a spatial index used to avoid checking every annotation against every tile. + + For each tile coordinate, the function shifts the tile ROI into slide space, queries the tree for intersecting geometries, unions the intersections, and transforms the result back into tile-relative coordinates. + That is why the returned geometry can be used directly to compute tile-local coverage such as `geometry.area / roi.area`. + + The `downsample` parameter matters because annotations are assumed to live at level 0. + The ROI and coordinates often refer to a lower-resolution working level, so the function explicitly transforms between the two spaces. + +## Option 2: Add Overlay Coverage + +Use `tile_overlay_overlap` when your source of truth is an aligned raster overlay and you only need overlap fractions rather than the full overlay patch. +Start from a tile metadata table that already includes the overlay path for each slide: + +```python +from typing import Any + +from ray.data.expressions import col +from shapely.geometry import box + +from ratiopath.ray import read_slides +from ratiopath.tiling import grid_tiles, tile_overlay_overlap + + +def expand_tiles(row: dict[str, Any]) -> list[dict[str, Any]]: + return [ + { + "path": row["path"], + "overlay_path": row["overlay_path"], + "tile_x": x, + "tile_y": y, + "mpp_x": row["mpp_x"], + "mpp_y": row["mpp_y"], + "tile_extent_x": row["tile_extent_x"], + "tile_extent_y": row["tile_extent_y"], + } + for x, y in grid_tiles( + slide_extent=(row["extent_x"], row["extent_y"]), + tile_extent=(row["tile_extent_x"], row["tile_extent_y"]), + stride=(row["stride_x"], row["stride_y"]), + last="keep", + ) + ] + + +slides = read_slides("data", mpp=0.5, tile_extent=512, stride=512) +slides = slides.map( + lambda row: { + **row, + "overlay_path": row["path"].replace(".svs", "_tissue_mask.tiff"), + } +) +tiles = slides.flat_map(expand_tiles) + +roi = box(0, 0, 512, 512) + +tiles_with_overlap = tiles.with_column( + "tissue_overlap", + tile_overlay_overlap( + roi=roi, + overlay_path=col("overlay_path"), + tile_x=col("tile_x"), + tile_y=col("tile_y"), + mpp_x=col("mpp_x"), + mpp_y=col("mpp_y"), + ), + num_cpus=1, + memory=4 * 1024**3, +) +``` + +If your overlay files follow a different naming convention, replace the `overlay_path` mapping with your own lookup. + +## Option 3: Attach The Aligned Overlay Patch + +Use `tile_overlay` when you need the overlay pixels themselves, for example to feed a model, inspect a mask visually, or compute custom patch-level statistics that are not expressible as simple per-value fractions. +Reusing the `tiles` dataset from the previous setup: + +```python +from ray.data.expressions import col +from shapely.geometry import box + +from ratiopath.tiling import tile_overlay + +roi = box(128, 128, 384, 384) + +tiles_with_overlay = tiles.with_column( + "tissue_overlay", + tile_overlay( + roi=roi, + overlay_path=col("overlay_path"), + tile_x=col("tile_x"), + tile_y=col("tile_y"), + mpp_x=col("mpp_x"), + mpp_y=col("mpp_y"), + ), + num_cpus=1, + memory=4 * 1024**3, +) +``` + +The resulting column stores two arrays per row: + +- the overlay data itself, +- and a mask whose `True` values mark pixels outside the ROI polygon. + +??? info "Under the hood for overlay coverage" + Overlay processing is different from annotation processing because the source is raster data, not geometry. + `tile_overlay_overlap` first resolves the overlay pyramid level closest to the tile resolution, scales the tile coordinates into that overlay space, and reads the corresponding overlay region. + + If you provide an arbitrary ROI, the implementation reads the ROI bounding box and then masks out pixels outside the ROI polygon. + The overlap function then counts the unique unmasked values and returns normalized fractions per value. + + This is cheaper than storing full overlay patches when all you need is tile-level summary metadata such as tissue fraction or class proportion. + +## How To Choose + +Use annotations when: + +- labels are authored as polygons or points, +- you need geometry-level control, +- or you want parser-based filtering before coverage is computed. + +Use overlays when: + +- labels already exist as aligned raster masks, +- you need fast per-tile value fractions, +- or you want the option to attach the overlay patch itself later. + +Use `tile_overlay` instead of `tile_overlay_overlap` when: + +- downstream code needs the raster patch itself, +- you want to run custom NumPy logic over the masked overlay, +- or you want to inspect or save the aligned overlay data for debugging. + +??? info "Geometry-first vs raster-first reasoning" + Choose the geometry path when labels are semantically authored objects and you need exact polygon behavior, class filtering, or later geometric operations. + Choose the raster path when labels are already materialized as aligned masks or heatmaps and your downstream logic depends on pixel fractions rather than on polygon topology. + + The two paths solve similar problems but operate in different representations. + Keeping that distinction clear helps avoid unnecessary format conversions and makes the pipeline easier to reason about. + +## Related API + +- [`ratiopath.parsers.ASAPParser`](../../reference/parsers/asap.md) +- [`ratiopath.parsers.GeoJSONParser`](../../reference/parsers/geojson.md) +- [`ratiopath.parsers.Darwin7JSONParser`](../../reference/parsers/darwin.md) +- [`ratiopath.tiling.annotations`](../../reference/tiling/annotations.md) +- [`ratiopath.tiling.overlays`](../../reference/tiling/overlays.md) diff --git a/docs/examples/basic-functionalities/extract-tiles-locally-without-ray.md b/docs/examples/basic-functionalities/extract-tiles-locally-without-ray.md new file mode 100644 index 0000000..b293b73 --- /dev/null +++ b/docs/examples/basic-functionalities/extract-tiles-locally-without-ray.md @@ -0,0 +1,94 @@ +# Extract Tiles Locally Without Ray + +!!! abstract "Overview" + **Problem solved:** extract and save tiles from a single slide when you want a small local workflow instead of a distributed Ray dataset. + + **Use this example when:** + + - you are prototyping on one slide, + - you need a quick patch dump for inspection or debugging, + - or you want to test tile geometry before scaling out. + +## Why This Approach + +The core tiling primitives do not require Ray. +You can combine `grid_tiles` with `OpenSlide.read_tile` to build a lightweight local extraction loop. + +!!! tip "Bundled example input" + The green outline on the left represents an annotation region. + The blue boxes show six example tile locations selected from that annotated region, and the right panel shows the corresponding locally saved crops. + +{ align=center } +*`grid_tiles` plans tile coordinates first, then a few representative crops are written to disk for inspection. In this demo figure, the annotation outline provides context for where those sample tiles were taken.* + +## Example + +```python +from pathlib import Path + +from PIL import Image + +from ratiopath.openslide import OpenSlide +from ratiopath.tiling import grid_tiles + +output_dir = Path("local_tiles") +output_dir.mkdir(exist_ok=True) + +with OpenSlide("slide.svs") as slide: + level = slide.closest_level(0.5) + extent_x, extent_y = slide.level_dimensions[level] + + saved = 0 + for i, (x, y) in enumerate( + grid_tiles( + slide_extent=(extent_x, extent_y), + tile_extent=(512, 512), + stride=(512, 512), + last="shift", + ) + ): + tile = slide.read_tile( + x=x, + y=y, + extent_x=512, + extent_y=512, + level=level, + ) + + if tile.std() <= 8: + continue + + Image.fromarray(tile).save(output_dir / f"tile_{i:05d}.png") + saved += 1 + + if saved == 64: + break +``` + +??? info "Under the hood" + `grid_tiles` only generates coordinates. + The actual image IO happens inside `read_tile`. + That keeps the local workflow easy to reason about because you can debug the coordinate plan and the pixel reads separately. + + In this example, `last="shift"` keeps edge tiles inside the selected level extent. + That is usually the safest choice for local extraction when you want consistent tile size on disk. + +## When To Prefer This Over Ray + +Use the local path when: + +- the slide count is small, +- you want to inspect outputs manually, +- or cluster scheduling overhead would dominate the workload. + +Use the Ray path when: + +- you need to process many slides, +- you need enrichment from annotations or overlays at scale, +- or you want to persist slide and tile metadata tables for reproducibility. + +## Related API + +- [`ratiopath.openslide.OpenSlide`](../../reference/openslide.md) +- [`ratiopath.tiling.tilers`](../../reference/tiling/tilers.md) +- [`ratiopath.tiling.read_slide_tiles`](../../reference/tiling/read_slide_tile.md) diff --git a/docs/examples/basic-functionalities/group-aware-splitting.md b/docs/examples/basic-functionalities/group-aware-splitting.md new file mode 100644 index 0000000..6db2eda --- /dev/null +++ b/docs/examples/basic-functionalities/group-aware-splitting.md @@ -0,0 +1,98 @@ +# Split Data With Groups + +!!! abstract "Overview" + **Problem solved:** create train/test splits that preserve class balance while preventing leakage across related samples such as patients, cases, or slides from the same specimen. + + **Use this example when:** + + - one biological unit contributes multiple samples, + - random splitting would leak information, + - and you still want class stratification. + +## Why This Approach + +`ratiopath.model_selection.train_test_split` extends the familiar scikit-learn interface with group-aware splitting. +If you need repeated split generation instead of one split, use `StratifiedGroupShuffleSplit` directly. + +{ align=center } +*The important rule is simple: all slides and all tiles from one patient or case must stay on the same side of the split.* + +## One-Shot Train/Test Split + +```python +import numpy as np + +from ratiopath.model_selection import train_test_split + +X = np.array(["tile_1", "tile_2", "tile_3", "tile_4", "tile_5", "tile_6"]) +y = np.array([0, 0, 1, 1, 0, 1]) +groups = np.array(["patient_a", "patient_a", "patient_b", "patient_b", "patient_c", "patient_c"]) + +X_train, X_test, y_train, y_test = train_test_split( + X, + y, + test_size=0.33, + random_state=42, + stratify=y, + groups=groups, +) +``` + +??? info "Under the hood" + `ratiopath.model_selection.train_test_split` mirrors the scikit-learn API, but switches splitter implementations depending on the constraints you provide. + + - If you pass only `stratify`, it behaves like a stratified shuffle split. + - If you pass only `groups`, it behaves like a group shuffle split. + - If you pass both, it uses `StratifiedGroupShuffleSplit`. + + That last case is the key extension in this repo. + The implementation repeatedly evaluates candidate group-respecting splits and chooses the one whose class distribution is closest to the global distribution. + This is an approximation, but it is much safer for pathology data than naive random splitting because tiles from the same patient or case cannot leak across the boundary. + +## Repeated Splits For Evaluation + +```python +import numpy as np + +from ratiopath.model_selection import StratifiedGroupShuffleSplit + +X = np.arange(12).reshape(6, 2) +y = np.array([0, 0, 1, 1, 0, 1]) +groups = np.array([1, 1, 2, 2, 3, 3]) + +splitter = StratifiedGroupShuffleSplit(n_splits=3, test_size=0.33, random_state=42) + +for train_index, test_index in splitter.split(X, y, groups): + print(train_index, test_index) +``` + +??? example "Example output" + ```text + [0 1 2 3] [4 5] + [2 3 4 5] [0 1] + [0 1 4 5] [2 3] + ``` + +??? info "Why repeated group-aware splits matter" + In pathology datasets, results can vary noticeably depending on which patients or slides land in the test set. + Using repeated group-aware splits gives you a more stable view of performance than a single random partition. + + This is especially useful when the number of groups is limited or the label distribution is imbalanced, because a single split can otherwise overstate or understate generalization quality. + +## When Not To Use Group-Aware Splitting + +You do not need the group-aware path when: + +- every row is independent, +- leakage between rows is not a concern, +- or there is no meaningful grouping variable to preserve. + +??? info "Failure mode this avoids" + Without grouping, tiles from the same specimen can appear in both training and test sets. + The model then sees very similar morphology during training and evaluation, and measured performance becomes optimistic. + The grouped splitter prevents that by moving the split decision up to the group level instead of the row level. + +## Related API + +- [`ratiopath.model_selection.split`](../../reference/model_selection/split.md) + diff --git a/docs/examples/basic-functionalities/inspect-slides-and-preview-tiles.md b/docs/examples/basic-functionalities/inspect-slides-and-preview-tiles.md new file mode 100644 index 0000000..d81e934 --- /dev/null +++ b/docs/examples/basic-functionalities/inspect-slides-and-preview-tiles.md @@ -0,0 +1,85 @@ +# Inspect Slides And Preview Tiles + +!!! abstract "Overview" + **Problem solved:** verify that a slide opens correctly, confirm the working resolution you want, and save a few preview regions before you launch a larger tiling job. + + **Use this example when:** + + - you are onboarding a new slide source, + - you want to sanity-check `mpp` or level choice visually, + - or you need to confirm that tiles, masks, or annotations will line up at the expected scale. + +## Why This Approach + +`OpenSlide` gives you direct access to slide pyramid metadata and patch reads. +This is the simplest way to inspect a single slide before you commit to a distributed Ray pipeline. + +!!! tip "Bundled example input" + The figure below shows the intended inspection workflow: a slide thumbnail for context, one larger preview region, and one saved tile for close inspection. + +{ align=center } +*The slide thumbnail marks one larger preview region in red and one saved tile in blue.* + +## Example + +```python +from pathlib import Path + +from PIL import Image + +from ratiopath.openslide import OpenSlide + +output_dir = Path("preview") +output_dir.mkdir(exist_ok=True) + +with OpenSlide("slide.svs") as slide: + level = slide.closest_level(0.5) + resolution = slide.slide_resolution(level) + extent = slide.level_dimensions[level] + + print(f"Selected level: {level}") + print(f"Resolution: {resolution}") + print(f"Extent: {extent}") + + region = slide.read_region_relative( + location=(0, 0), + level=level, + size=(2048, 2048), + ).convert("RGB") + region.save(output_dir / "region_preview.png") + + tile = slide.read_tile( + x=1024, + y=1024, + extent_x=512, + extent_y=512, + level=level, + ) + Image.fromarray(tile).save(output_dir / "tile_preview.png") +``` + +??? example "Example output" + ```text + Selected level: 2 + Resolution: (0.5, 0.5) + Extent: (16384, 12288) + ``` + +??? info "Under the hood" + `closest_level` compares your target physical resolution with the available pyramid levels and picks the nearest one. + `read_region_relative` and `read_tile` then interpret coordinates in that chosen level's coordinate system. + + This is important for debugging because it lets you reason in the same working space that later tiling code will use. + If a preview looks wrong here, the larger distributed pipeline will be wrong too. + +## What To Check + +- whether the selected level matches the physical scale you expect, +- whether bright background and tissue look sensible at that scale, +- whether the saved tile boundaries correspond to the intended region, +- and whether any overlay or annotation source uses the same slide orientation. + +## Related API + +- [`ratiopath.openslide.OpenSlide`](../../reference/openslide.md) +- [`ratiopath.ray.read_slides`](../../reference/ray/read_slides.md) diff --git a/docs/examples/basic-functionalities/slide-metadata-and-resolution.md b/docs/examples/basic-functionalities/slide-metadata-and-resolution.md new file mode 100644 index 0000000..7ff56d9 --- /dev/null +++ b/docs/examples/basic-functionalities/slide-metadata-and-resolution.md @@ -0,0 +1,103 @@ +# Read Slides And Choose Resolution + +!!! abstract "Overview" + **Problem solved:** create a slide-level Ray dataset and normalize different whole-slide image formats onto one working resolution before you start tiling. + + **Use this example when:** + + - your input directory contains multiple slide files, + - you want `ratiopath` to select the closest matching pyramid level automatically, + - and you want later stages to work from metadata instead of opening pixel data immediately. + +## Why This Approach + +`read_slides` is the metadata entry point for distributed processing. +It reads slide dimensions, tile parameters, chosen level, and physical resolution into a Ray `Dataset`. +That lets you postpone expensive tile reads until you know which regions you actually need. + +{ align=center } +*Selecting by `mpp` means each slide can land on a different pyramid level while still producing a consistent physical working scale.* + +## Example + +```python +from ratiopath.ray import read_slides + +slides = read_slides( + paths="data/", + mpp=0.25, + tile_extent=1024, + stride=960, +) + +slides.show(3) +``` + +??? example "Example output" + ```text + +--------------------------------------+----------+----------+---------------+---------------+----------+----------+-------+-------+-------+------------+ + | path | extent_x | extent_y | tile_extent_x | tile_extent_y | stride_x | stride_y | mpp_x | mpp_y | level | downsample | + +======================================+==========+==========+===============+===============+==========+==========+=======+=======+=======+============+ + | /data/slide_a.svs | 84320 | 61120 | 1024 | 1024 | 960 | 960 | 0.25 | 0.25 | 2 | 4.0 | + | /data/slide_b.ome.tif | 51200 | 40960 | 1024 | 1024 | 960 | 960 | 0.25 | 0.25 | 1 | 2.0 | + | /data/slide_c.ndpi | 92160 | 70656 | 1024 | 1024 | 960 | 960 | 0.25 | 0.25 | 3 | 8.0 | + +--------------------------------------+----------+----------+---------------+---------------+----------+----------+-------+-------+-------+------------+ + ``` + +??? info "Under the hood" + `read_slides` builds a Ray `Dataset` by delegating file discovery and slide metadata extraction to `SlideMetaDatasource`. + For each input path, the datasource opens the slide through the appropriate backend, resolves either the requested `mpp` or explicit `level`, and emits one metadata row per slide. + + The important design choice is that no tile pixels are read here. + This stage only computes the information later steps need to choose tile coordinates and read the correct pyramid level. + That keeps the first stage cheap to parallelize and makes it practical to inspect, repartition, cache, or persist the metadata table before any heavy image IO starts. + + When you pass `mpp`, the backend computes the closest available slide level by comparing the physical resolution of each level with your target. + That is why slides with different scanner magnifications can still enter the same downstream workflow with a normalized working scale. + +## What You Get + +Each row contains metadata for one slide, typically including: + +- `path` +- `extent_x`, `extent_y` +- `tile_extent_x`, `tile_extent_y` +- `stride_x`, `stride_y` +- `mpp_x`, `mpp_y` +- `level` +- `downsample` + +## When To Use `mpp` Instead Of `level` + +Use `mpp` when: + +- your slides were scanned at different magnifications, +- you care about physical scale consistency, +- or you want the closest available level selected automatically per slide. + +Use `level` when: + +- all slides share the same pyramid structure, +- and you need an exact pyramid level for reproducibility or compatibility with an external workflow. + +```python +slides = read_slides( + paths=["slide_a.svs", "slide_b.ome.tif"], + level=2, + tile_extent=(512, 512), + stride=(512, 512), +) +``` + +??? info "How level selection differs from MPP selection" + `level` is an index into the slide pyramid, so it is exact but scanner-dependent. + If two scanners use different downsample schedules, `level=2` may correspond to different physical resolutions. + + `mpp` is a physical target, so it is scanner-agnostic but approximate. + `ratiopath` resolves it to the nearest available level for each slide and stores the chosen `mpp_x`, `mpp_y`, `level`, and `downsample` in the resulting dataset. + That makes later stages explicit about the actual working scale instead of assuming all slides share the same pyramid layout. + +## Related API + +- [`ratiopath.ray.read_slides`](../../reference/ray/read_slides.md) +- [`ratiopath.openslide.OpenSlide`](../../reference/openslide.md) diff --git a/docs/examples/basic-functionalities/stain-augmentation.md b/docs/examples/basic-functionalities/stain-augmentation.md new file mode 100644 index 0000000..d75577c --- /dev/null +++ b/docs/examples/basic-functionalities/stain-augmentation.md @@ -0,0 +1,102 @@ +# Apply Stain Augmentation + +!!! abstract "Overview" + **Problem solved:** augment H&E-like tiles by perturbing stain channels rather than only applying generic RGB transforms. + + **Use this example when:** + + - you are training a model on histopathology tiles, + - stain variation across labs or scanners matters, + - or you want image-dependent stain matrices instead of a single fixed transform. + +## Why This Approach + +`StainAugmentor` is an Albumentations transform. +It separates an image into stain space, perturbs each stain channel, and then reconstructs the RGB image. +You can provide either: + +- a fixed stain-conversion matrix, +- or a callable that estimates the matrix from each image. + +!!! tip "Bundled example input" + The figure below shows the two augmentation modes discussed on this page. + The middle panel follows the adaptive-matrix workflow, while the right panel follows the fixed-`HE` workflow shown later on this page. + +{ align=center } +*Using one real crop for all three panels makes it easier to see how the adaptive and fixed-matrix variants differ in practice.* + +## Example With An Adaptive Matrix + +```python +import numpy as np + +from ratiopath.augmentations import StainAugmentor, estimate_stain_vectors +from ratiopath.augmentations.estimate_stain_vectors import HE +from ratiopath.openslide import OpenSlide + +with OpenSlide("slide_a.tiff") as slide: + tile = slide.read_tile( + x=9216, + y=7168, + extent_x=2048, + extent_y=2048, + level=0, + ) + +augmentor = StainAugmentor( + conv_matrix=lambda image: estimate_stain_vectors(image, HE), + alpha=0.05, + beta=0.05, + p=1.0, +) + +augmented_tile = augmentor(image=tile)["image"] +``` + +??? example "Expected outputs" + `augmented_tile` stays in the same image space as the input tile: + + - shape: `(2048, 2048, 3)` + - dtype: `uint8` + + The pixel values change, but the tile dimensions and layout do not. + +??? info "Under the hood" + `estimate_stain_vectors` applies an optical-density transform, discards unsuitable pixels, and estimates a stain basis from the remaining signal. + `StainAugmentor` then samples multiplicative and additive perturbations for each stain channel before reconstructing the RGB image. + + This is different from ordinary color jitter because the perturbation happens in stain space rather than directly in RGB. + +## Example With A Fixed Matrix + +```python +from ratiopath.augmentations import StainAugmentor +from ratiopath.augmentations.estimate_stain_vectors import HE + +augmentor = StainAugmentor( + conv_matrix=HE, + alpha=0.02, + beta=0.02, + p=1.0, +) +``` + +??? example "Expected outputs" + The fixed-matrix version produces the same kind of output array as the adaptive version. + The difference is only in how the stain basis is chosen, not in the output tensor shape or datatype. + +Use the fixed-matrix version when you want deterministic stain semantics across tiles and the adaptive version when slide-to-slide stain variation is large. + +## Where This Fits In A Pipeline + +This is usually a training-time step, not a Ray metadata step. +Common usage looks like: + +- build a tile dataset with `read_slide_tiles`, +- load tiles into a model input pipeline, +- apply `StainAugmentor` during training augmentation. + +## Related API + +- [`ratiopath.augmentations.StainAugmentor`](../../reference/augmentations/stain_augmentor.md) +- [`ratiopath.augmentations.estimate_stain_vectors`](../../reference/augmentations/estimate_stain_vectors.md) diff --git a/docs/examples/basic-functionalities/tensor-aggregations.md b/docs/examples/basic-functionalities/tensor-aggregations.md new file mode 100644 index 0000000..9c86299 --- /dev/null +++ b/docs/examples/basic-functionalities/tensor-aggregations.md @@ -0,0 +1,86 @@ +# Aggregate Tensor Columns In Ray + +!!! abstract "Overview" + **Problem solved:** compute dataset-wide means or standard deviations over tensor-valued columns without collecting all rows back to the driver. + + **Use this example when:** + + - your Ray dataset contains embeddings, feature maps, or image tensors, + - you need normalization statistics, + - or you want a reduction that runs across distributed Ray blocks. + +## Why This Approach + +`TensorMean` and `TensorStd` are custom Ray aggregations designed for tensor-valued columns. +They preserve Ray's distributed execution model and avoid building your own manual reduction logic. + +## Example + +```python +import numpy as np +import ray + +from ratiopath.ray.aggregate import TensorMean, TensorStd + +ray.init() + +ds = ray.data.from_items( + [ + {"embedding": np.array([1.0, 2.0, 3.0])}, + {"embedding": np.array([3.0, 4.0, 5.0])}, + {"embedding": np.array([5.0, 6.0, 7.0])}, + ] +) + +summary = ds.aggregate( + TensorMean(on="embedding", axis=0, alias_name="embedding_mean"), + TensorStd(on="embedding", axis=0, ddof=1.0, alias_name="embedding_std"), +) + +print(summary["embedding_mean"]) +print(summary["embedding_std"]) +``` + +??? example "Example output" + ```text + [3. 4. 5.] + [2. 2. 2.] + ``` + +??? info "Under the hood" + These aggregators treat dataset rows as the batch dimension. + That means axis `0` is always the row/batch axis of the distributed dataset, not just a dimension inside each tensor. + + `TensorMean` accumulates partial sums. + `TensorStd` uses a parallel variance-combination algorithm so the standard deviation remains numerically stable across multiple Ray blocks. + +## Choosing The Axis + +Use `axis=None` when: + +- you want one scalar over every value in every row. + +Use `axis=0` when: + +- you want the elementwise statistic across rows. + +Use `axis=1` or `axis=(0, 1)` when: + +- you want to reduce both across rows and across the first tensor dimension. + +```python +feature_map_stats = ds.aggregate( + TensorMean(on="feature_map", axis=(0, 1), alias_name="channel_mean"), + TensorStd(on="feature_map", axis=(0, 1), alias_name="channel_std"), +) +``` + +??? info "Important constraint" + These are reduction operators. + If you supply a tuple of axes, it must include `0` because the implementation is designed to reduce across the Ray batch dimension. + If you need per-row statistics instead, use `map()` or `map_batches()` and compute them directly on each row. + +## Related API + +- [`ratiopath.ray.aggregate.TensorMean`](../../reference/ray/aggregations/tensor_mean.md) +- [`ratiopath.ray.aggregate.TensorStd`](../../reference/ray/aggregations/tensor_std.md) diff --git a/docs/examples/basic-functionalities/tile-grid-generation.md b/docs/examples/basic-functionalities/tile-grid-generation.md new file mode 100644 index 0000000..691cd0f --- /dev/null +++ b/docs/examples/basic-functionalities/tile-grid-generation.md @@ -0,0 +1,131 @@ +# Generate Tile Coordinates + +!!! abstract "Overview" + **Problem solved:** generate tile coordinates for dense tiling, overlapping tiling, or edge-aware extraction without reading slide pixels yet. + + **Use this example when:** + + - you want deterministic tile coordinates, + - you need to control overlap with `stride`, + - or you need explicit behavior for partial tiles at the slide boundary. + +## Why This Approach + +`grid_tiles` only generates coordinates. +That makes it the right tool when you want to inspect or filter the sampling plan before any expensive image IO happens. + +{ align=center } +*`drop`, `keep`, and `shift` differ only at the slide boundary, but that difference controls whether you lose coverage or allow edge tiles.* + +## Example + +```python +from ratiopath.tiling import grid_tiles + +coordinates = list( + grid_tiles( + slide_extent=(8192, 6144), + tile_extent=(1024, 1024), + stride=(960, 960), + last="keep", + ) +) + +print(coordinates[:3]) +``` + +??? example "Example output" + ```text + [array([0, 0]), array([960, 0]), array([1920, 0])] + ``` + +??? info "Under the hood" + `grid_tiles` does not inspect slide pixels. + It only operates on three vectors: slide extent, tile extent, and stride. + From those values it computes the maximum tile index per dimension and yields tile origins in row-major order. + + The `last` strategy changes how the upper edge of the coordinate range is computed: + + - `drop` uses floor division behavior and emits only tiles that fit cleanly. + - `keep` uses ceiling behavior and may emit a final tile that extends beyond the nominal slide bounds. + - `shift` also uses ceiling behavior, but clamps the final coordinates so the last tile stays inside the slide extent. + + Because this step is pure coordinate generation, it is cheap enough to run before any expensive backend reads. + That is why it works well as the expansion step inside `flat_map`. + +## Choosing `last` + +Use `last="keep"` when: + +- you want full grid coverage, +- and you can handle tiles that extend beyond the slide bounds downstream. + +Use `last="drop"` when: + +- you only want tiles that fit the slide extent cleanly. + +Use `last="shift"` when: + +- you want edge coverage, +- but still need each tile to stay inside the slide extent. + +```python +edge_safe_coordinates = list( + grid_tiles( + slide_extent=(5000, 5000), + tile_extent=(1024, 1024), + stride=(1024, 1024), + last="shift", + ) +) +``` + +??? info "Why this is useful in Ray pipelines" + Separating coordinate generation from tile reading lets you keep the intermediate dataset small and structured. + You can add slide IDs, annotation paths, cohort labels, or filtering rules while still working with scalar metadata. + + Only after repartitioning and pruning do you call `read_slide_tiles`. + That typically improves throughput because the expensive image decode work happens on a smaller, better-balanced dataset. + +## Turning Coordinates Into Rows + +{ align=center } +*One slide row becomes many tile rows because downstream transforms need tile coordinates and slide context on every record.* + +In a Ray workflow, you usually expand one slide row into many tile rows: + +```python +from typing import Any + +from ratiopath.tiling import grid_tiles + + +def expand_tiles(row: dict[str, Any]) -> list[dict[str, int | str]]: + return [ + { + "path": row["path"], + "tile_x": x, + "tile_y": y, + "level": row["level"], + "tile_extent_x": row["tile_extent_x"], + "tile_extent_y": row["tile_extent_y"], + } + for x, y in grid_tiles( + slide_extent=(row["extent_x"], row["extent_y"]), + tile_extent=(row["tile_extent_x"], row["tile_extent_y"]), + stride=(row["stride_x"], row["stride_y"]), + last="keep", + ) + ] +``` + +??? info "What the expansion step is doing" + The expansion function copies slide-level fields such as `path`, `level`, and tile dimensions into every output row because downstream readers need those columns per tile. + Conceptually, one slide row is being denormalized into many tile rows. + + That may look repetitive, but it fits Ray Data well because each tile becomes an independently schedulable record that can later be filtered, enriched, or joined with other tile-level signals. + +## Related API + +- [`ratiopath.tiling.tilers`](../../reference/tiling/tilers.md) +- [`ratiopath.tiling.read_slide_tiles`](../../reference/tiling/read_slide_tile.md) diff --git a/docs/examples/basic-functionalities/tissue-mask-generation.md b/docs/examples/basic-functionalities/tissue-mask-generation.md new file mode 100644 index 0000000..8f7f204 --- /dev/null +++ b/docs/examples/basic-functionalities/tissue-mask-generation.md @@ -0,0 +1,167 @@ +# Generate A Tissue Mask Overlay + +!!! abstract "Overview" + **Problem solved:** create a reusable raster tissue mask from a slide so later pipelines can filter tiles by tissue coverage without repeatedly decoding RGB tiles. + + **Use this example when:** + + - you do not already have a tissue mask overlay, + - you want a cheap reusable artifact for tile filtering or QC, + - or you want to customize the mask-generation filter chain. + +## Why This Approach + +`tissue_mask` runs a `pyvips` filter pipeline over a whole-slide image and returns the mask image. +The default pipeline currently applies: + +- grayscale conversion, +- Otsu thresholding, +- morphological closing, +- morphological opening. + +That gives you a raster mask that can later be consumed by `tile_overlay` or `tile_overlay_overlap`. + +!!! tip "Bundled example input" + The figure below shows the intended result of this workflow: + an original slide view, the corresponding binary tissue mask, and an overlay blend that makes the alignment easy to verify. + +{ align=center } +*The included overlay file makes it easier to understand what `tissue_mask` is expected to produce before you generate your own mask outputs.* + +## Example With The Default Filter + +```python +import pyvips + +from ratiopath.masks import tissue_mask + +slide = pyvips.Image.new_from_file("slide.ome.tif", access="sequential") + +mask = tissue_mask( + slide=slide, + mpp=(8.0, 8.0), +) + +mask.tiffsave( + "slide_tissue_mask.tiff", + tile=True, + pyramid=True, + bigtiff=True, + compression="lzw", +) +``` + +??? info "Under the hood" + `tissue_mask` itself is thin. + The actual behavior comes from the `VipsFilter` object you pass in. + With the default filter, the image is converted to grayscale, thresholded with multi-Otsu logic, and then cleaned up with morphology whose structuring-element size depends on the supplied `mpp`. + + That `mpp` argument is important because the morphology radius is scaled in physical units rather than in raw pixel counts. + This makes the mask behavior more stable across slides processed at different resolutions. + +## Example With A Custom Filter Chain + +```python +import pyvips + +from ratiopath.masks import tissue_mask +from ratiopath.masks.vips_filters import ( + VipsClosing, + VipsCompose, + VipsOpening, + VipsOtsu, + VipsSaturationFilter, +) + +slide = pyvips.Image.new_from_file("slide.ome.tif", access="sequential") + +custom_filter = VipsCompose( + [ + VipsSaturationFilter(), + VipsOtsu(), + VipsOpening(disc_factor=6), + VipsClosing(disc_factor=6), + ] +) + +mask = tissue_mask( + slide=slide, + mpp=(8.0, 8.0), + filter=custom_filter, +) +``` + +??? example "Expected outputs" + The custom-filter example returns an in-memory `pyvips.Image` mask. + If you save it, the output should be another aligned binary-like overlay that can be consumed by `tile_overlay` or `tile_overlay_overlap`. + +??? info "Why customize the filter" + The default grayscale path is a reasonable general-purpose choice, but some stains or scanner profiles separate tissue from background more cleanly in saturation space. + By switching the first stage to `VipsSaturationFilter`, you can keep the rest of the pipeline unchanged while changing the signal that drives thresholding. + +## How To Use The Output + +After you save the mask, treat it as an aligned overlay in later tile pipelines. +The snippet below rebuilds a tile metadata table and adds the saved mask path before computing overlap: + +```python +from typing import Any + +from ray.data.expressions import col +from shapely.geometry import box + +from ratiopath.ray import read_slides +from ratiopath.tiling import grid_tiles, tile_overlay_overlap + + +def expand_tiles(row: dict[str, Any]) -> list[dict[str, Any]]: + return [ + { + "path": row["path"], + "tissue_mask_path": row["tissue_mask_path"], + "tile_x": x, + "tile_y": y, + "mpp_x": row["mpp_x"], + "mpp_y": row["mpp_y"], + "tile_extent_x": row["tile_extent_x"], + "tile_extent_y": row["tile_extent_y"], + } + for x, y in grid_tiles( + slide_extent=(row["extent_x"], row["extent_y"]), + tile_extent=(row["tile_extent_x"], row["tile_extent_y"]), + stride=(row["stride_x"], row["stride_y"]), + last="keep", + ) + ] + + +slides = read_slides("data", mpp=0.5, tile_extent=512, stride=512) +slides = slides.map( + lambda row: { + **row, + "tissue_mask_path": row["path"].replace(".ome.tif", "_tissue_mask.tiff"), + } +) +tiles = slides.flat_map(expand_tiles) + +roi = box(0, 0, 512, 512) + +tiles = tiles.with_column( + "tissue_overlap", + tile_overlay_overlap( + roi=roi, + overlay_path=col("tissue_mask_path"), + tile_x=col("tile_x"), + tile_y=col("tile_y"), + mpp_x=col("mpp_x"), + mpp_y=col("mpp_y"), + ), +) +``` + +This is usually cheaper than reading RGB tiles just to decide whether a patch is mostly background. + +## Related API + +- [`ratiopath.masks.tissue_mask`](../../reference/masks/tissue_mask.md) +- [`ratiopath.tiling.overlays`](../../reference/tiling/overlays.md) diff --git a/docs/examples/basic-functionalities/write-tiff-outputs-from-ray.md b/docs/examples/basic-functionalities/write-tiff-outputs-from-ray.md new file mode 100644 index 0000000..779a847 --- /dev/null +++ b/docs/examples/basic-functionalities/write-tiff-outputs-from-ray.md @@ -0,0 +1,99 @@ +# Write TIFF Outputs From Ray + +!!! abstract "Overview" + **Problem solved:** save NumPy image arrays from a Ray dataset as TIFF files without writing your own row-by-row export loop. + + **Use this example when:** + + - you already have image arrays in a Ray dataset, + - you want one TIFF per row, + - or you want to export masks, patches, or derived images for later inspection. + +## Why This Approach + +`VipsTiffDatasink` wraps `pyvips` inside a Ray datasink so dataset rows can be written directly to TIFF. +This is useful when your pipeline already lives in Ray and the natural output is a directory of images rather than another Parquet table. + +## Example + +```python +from ray.data.expressions import col + +from ratiopath.ray import read_slides +from ratiopath.ray.datasource import VipsTiffDatasink +from ratiopath.tiling import grid_tiles, read_slide_tiles +from ratiopath.tiling.utils import row_hash + + +def expand_tiles(row: dict[str, object]) -> list[dict[str, object]]: + return [ + { + "path": row["path"], + "slide_id": row["id"], + "tile_x": x, + "tile_y": y, + "level": row["level"], + "tile_extent_x": row["tile_extent_x"], + "tile_extent_y": row["tile_extent_y"], + } + for x, y in grid_tiles( + slide_extent=(row["extent_x"], row["extent_y"]), + tile_extent=(row["tile_extent_x"], row["tile_extent_y"]), + stride=(row["stride_x"], row["stride_y"]), + last="keep", + ) + ] + + +slides = read_slides("data", mpp=0.5, tile_extent=512, stride=512).map(row_hash) +tiles = slides.flat_map(expand_tiles).limit(32) + +tiles_with_pixels = tiles.with_column( + "tile", + read_slide_tiles( + col("path"), + col("tile_x"), + col("tile_y"), + col("tile_extent_x"), + col("tile_extent_y"), + col("level"), + ), +) + +tiles_with_pixels.write_datasink( + VipsTiffDatasink( + path="tile_tiffs", + data_column="tile", + default_options={ + "compression": "lzw", + "tile": True, + "bigtiff": True, + }, + ) +) +``` + +??? info "Under the hood" + `VipsTiffDatasink` expects a column containing NumPy arrays. + For each row, it converts that array into a `pyvips.Image` and writes a TIFF buffer with the options you provide. + + This keeps export inside the distributed pipeline instead of forcing a manual collect-and-save step on the driver. + +## When This Is Better Than Parquet + +Use TIFF export when: + +- another tool expects image files, +- you want to inspect outputs visually, +- or you are exporting masks or derived rasters rather than metadata. + +Use Parquet when: + +- the output is mostly scalar metadata, +- you want efficient downstream filtering and joins, +- or the image pixels are only an intermediate step. + +## Related API + +- [`ratiopath.ray.datasource.VipsTiffDatasink`](../../reference/ray/vips_tiff_datasink.md) +- [`ratiopath.tiling.read_slide_tiles`](../../reference/tiling/read_slide_tile.md) diff --git a/docs/examples/index.md b/docs/examples/index.md new file mode 100644 index 0000000..c2568dd --- /dev/null +++ b/docs/examples/index.md @@ -0,0 +1,73 @@ +# Usage Examples + +This hub collects task-focused examples that show how to use `ratiopath` for common pathology data tasks. + +The examples are organized by user intent. +Start with **Basic Functionalities** if you need one focused operation. +Move to **Pipelines** when you want to combine multiple `ratiopath` components into an end-to-end workflow. + +## Basic Functionalities + +### [Read Slides And Choose Resolution](./basic-functionalities/slide-metadata-and-resolution.md) + +Problem solved: create a Ray dataset of slide metadata and pick a consistent working resolution across mixed whole-slide image files. + +### [Inspect Slides And Preview Tiles](./basic-functionalities/inspect-slides-and-preview-tiles.md) + +Problem solved: validate slide opening, level selection, and tile geometry visually before scaling to a larger workflow. + +### [Generate Tile Coordinates](./basic-functionalities/tile-grid-generation.md) + +Problem solved: generate reproducible tile coordinates for dense extraction, overlapping tiles, or edge-aware tiling without reading image pixels yet. + +### [Extract Tiles Locally Without Ray](./basic-functionalities/extract-tiles-locally-without-ray.md) + +Problem solved: extract and save tiles from a single slide using the core tiling primitives without building a distributed Ray dataset. + +### [Add Annotation And Overlay Signals](./basic-functionalities/annotation-and-overlay-enrichment.md) + +Problem solved: enrich tile metadata with geometry-based annotations or overlay-derived coverage values so that later filtering and training steps stay metadata-driven. + +### [Split Data With Groups](./basic-functionalities/group-aware-splitting.md) + +Problem solved: create train/test splits that preserve class balance while preventing leakage between related samples such as patients, slides, or cases. + +### [Generate A Tissue Mask Overlay](./basic-functionalities/tissue-mask-generation.md) + +Problem solved: turn a whole-slide image into a reusable raster tissue mask that later overlay-aware pipelines can query cheaply. + +### [Aggregate Tensor Columns In Ray](./basic-functionalities/tensor-aggregations.md) + +Problem solved: compute distributed mean and standard-deviation statistics over tensor-valued Ray dataset columns. + +### [Apply Stain Augmentation](./basic-functionalities/stain-augmentation.md) + +Problem solved: perturb histology tiles in stain space so training data better reflects scanner and lab variation. + +### [Write TIFF Outputs From Ray](./basic-functionalities/write-tiff-outputs-from-ray.md) + +Problem solved: export image arrays from a Ray dataset directly to TIFF files for inspection or downstream tools. + +## Pipelines + +### [Build A Distributed Tiling Pipeline](./pipelines/distributed-tiling-pipeline.md) + +Problem solved: go from raw slide files to a scalable tile dataset that can be filtered, persisted, and reused for training or batch inference. + +### [Create An Annotation-Aware Tile Dataset](./pipelines/annotation-aware-dataset-pipeline.md) + +Problem solved: combine slide metadata, tile coordinates, and annotation coverage into one dataset suitable for supervised pathology workflows. + +### [Build A Mask-First Tile Filtering Pipeline](./pipelines/mask-first-tile-filtering-pipeline.md) + +Problem solved: generate reusable tissue-mask overlays and use them to filter tiles before expensive RGB tile reads. + +### [Prepare A Training Dataset With Leakage-Safe Splits](./pipelines/prepare-a-training-dataset-with-leakage-safe-splits.md) + +Problem solved: split at the slide or patient level first, then build train and test tile datasets without information leakage. + +## How To Use This Section + +Pick the example that matches the problem you are solving, run the minimal version first, and then adapt it to your own slide format, tile size, annotation source, or storage layout. +Some pages build a slide-level or tile-level metadata table first and then reuse it in later snippets on the same page. +Each page links back to the relevant API surface so you can move from workflow guidance to exact function signatures without leaving the docs structure. diff --git a/docs/examples/pipelines/annotation-aware-dataset-pipeline.md b/docs/examples/pipelines/annotation-aware-dataset-pipeline.md new file mode 100644 index 0000000..3cc4396 --- /dev/null +++ b/docs/examples/pipelines/annotation-aware-dataset-pipeline.md @@ -0,0 +1,139 @@ +# Create An Annotation-Aware Tile Dataset + +!!! abstract "Overview" + **Problem solved:** build a tile dataset that carries annotation coverage per tile so you can train or filter on biologically meaningful regions rather than on raw tile coordinates alone. + + **Use this pipeline when:** + + - you have slide-level annotation files, + - you need tile-level supervision or coverage thresholds, + - and you want one dataset that combines slide metadata, tile coordinates, and annotation-derived signals. + +## Workflow + +1. Read slide metadata with `read_slides`. +2. Locate the corresponding annotation file for each slide. +3. Parse the annotation geometry. +4. Generate tile coordinates with `grid_tiles`. +5. Intersect annotations against each tile ROI with `tile_annotations`. +6. Store coverage in the output rows. + +{ align=center } +*This pipeline first explodes one slide row into many tile rows, then attaches an `annotation_coverage` value to each tile.* + +## Example Tile Rows + +The table below shows representative tile rows from one annotated slide with `tile_extent=1024` and `stride=1024`. +These are the kinds of rows produced after the `flat_map(tiles_with_coverage)` step: + +| path | tile_x | tile_y | annotation_coverage | +| --- | ---: | ---: | ---: | +| `sample_slide.tiff` | 14336 | 12288 | 0.210 | +| `sample_slide.tiff` | 8192 | 11264 | 0.408 | +| `sample_slide.tiff` | 10240 | 15360 | 0.841 | +| `sample_slide.tiff` | 10240 | 8192 | 1.000 | + +This is usually the key conceptual shift in the pipeline: one slide row turns into many tile rows, and each row carries a quantitative label rather than just raw coordinates. + +## Example + +```python +from typing import Any + +import numpy as np +from shapely import Polygon + +from ratiopath.parsers import ASAPParser +from ratiopath.ray import read_slides +from ratiopath.tiling import grid_tiles, tile_annotations +from ratiopath.tiling.utils import row_hash + + +def add_annotation_path(row: dict[str, Any]) -> dict[str, Any]: + row["annotation_path"] = row["path"].replace(".mrxs", ".xml") + return row + + +def tiles_with_coverage(row: dict[str, Any]) -> list[dict[str, Any]]: + parser = ASAPParser(row["annotation_path"]) + annotations = list(parser.get_polygons(name="Tumor.*")) + + roi = Polygon( + [ + (0, 0), + (row["tile_extent_x"], 0), + (row["tile_extent_x"], row["tile_extent_y"]), + (0, row["tile_extent_y"]), + ] + ) + + coordinates = np.array( + list( + grid_tiles( + slide_extent=(row["extent_x"], row["extent_y"]), + tile_extent=(row["tile_extent_x"], row["tile_extent_y"]), + stride=(row["stride_x"], row["stride_y"]), + last="keep", + ) + ) + ) + + return [ + { + "slide_id": row["id"], + "path": row["path"], + "tile_x": coordinates[i, 0], + "tile_y": coordinates[i, 1], + "annotation_coverage": geometry.area / roi.area, + } + for i, geometry in enumerate( + tile_annotations( + annotations=annotations, + roi=roi, + coordinates=coordinates, + downsample=row["downsample"], + ) + ) + ] + + +slides = read_slides("data", mpp=0.25, tile_extent=512, stride=512) +slides = slides.map(row_hash).map(add_annotation_path) + +tiles = slides.flat_map(tiles_with_coverage) +positive_tiles = tiles.filter(lambda row: row["annotation_coverage"] >= 0.5) +``` + +??? info "Under the hood" + This pipeline combines two spaces that must stay aligned: + + - slide space, where annotations are authored and slide metadata is resolved, + - tile space, where each output row represents one fixed-size crop. + + The parser first converts annotation files into Shapely geometries. + Then `grid_tiles` defines the tile origins for the chosen working resolution. + `tile_annotations` handles the coordinate transformation between the working level and level 0 annotation space, intersects the tile ROI with the annotation set, and returns tile-local geometries. + + By storing coverage rather than the raw polygon set in each output row, the resulting dataset stays compact and works well with later thresholding, sampling, and model training steps. + +## Adapting The Pipeline + +- Replace `ASAPParser` with `GeoJSONParser` or `Darwin7JSONParser` when your source annotations use those formats. +- Change the ROI if you want to score only a subregion inside each tile. +- Keep all rows if you need continuous coverage values instead of thresholded selection. + +??? info "Why ROI choice matters" + The ROI is the geometry whose overlap you are actually measuring. + If the ROI is the full tile, coverage reflects the proportion of the complete tile occupied by the annotation. + If the ROI is a subregion, the same annotation can produce a very different coverage value because the denominator changes. + + That makes ROI design an important modeling decision, not just a technical parameter. + It controls what a positive tile means in the final dataset. + +## Related API + +- [`ratiopath.parsers.ASAPParser`](../../reference/parsers/asap.md) +- [`ratiopath.parsers.GeoJSONParser`](../../reference/parsers/geojson.md) +- [`ratiopath.parsers.Darwin7JSONParser`](../../reference/parsers/darwin.md) +- [`ratiopath.tiling.annotations`](../../reference/tiling/annotations.md) +- [`ratiopath.tiling.tilers`](../../reference/tiling/tilers.md) diff --git a/docs/examples/pipelines/distributed-tiling-pipeline.md b/docs/examples/pipelines/distributed-tiling-pipeline.md new file mode 100644 index 0000000..47c0ee0 --- /dev/null +++ b/docs/examples/pipelines/distributed-tiling-pipeline.md @@ -0,0 +1,117 @@ +# Build A Distributed Tiling Pipeline + +!!! abstract "Overview" + **Problem solved:** turn a directory of whole-slide images into a scalable tile dataset that can be filtered and saved for model training or large-batch inference. + + **Use this pipeline when:** + + - you need dense tile extraction, + - you want to keep slide metadata and tile metadata in Ray, + - and you only want to read pixels at the point where they become necessary. + +## Workflow + +1. Read slide metadata with `read_slides`. +2. Add a stable slide identifier. +3. Expand each slide into tile coordinates with `grid_tiles`. +4. Repartition tiles for better parallelism. +5. Read tile pixels with `read_slide_tiles`. +6. Filter tiles and write the result. + +{ align=center } +*The pipeline stays metadata-only until tile pixels are actually needed, which is why it scales better than eager extraction.* + +## Example + +```python +from typing import Any + +from ray.data.expressions import col + +from ratiopath.ray import read_slides +from ratiopath.tiling import grid_tiles, read_slide_tiles +from ratiopath.tiling.utils import row_hash + + +def expand_tiles(row: dict[str, Any]) -> list[dict[str, Any]]: + return [ + { + "tile_x": x, + "tile_y": y, + "path": row["path"], + "slide_id": row["id"], + "level": row["level"], + "tile_extent_x": row["tile_extent_x"], + "tile_extent_y": row["tile_extent_y"], + } + for x, y in grid_tiles( + slide_extent=(row["extent_x"], row["extent_y"]), + tile_extent=(row["tile_extent_x"], row["tile_extent_y"]), + stride=(row["stride_x"], row["stride_y"]), + last="keep", + ) + ] + + +slides = read_slides("data", mpp=0.25, tile_extent=1024, stride=960) +slides = slides.map(row_hash, num_cpus=0.1, memory=128 * 1024**2) + +tiles = slides.flat_map(expand_tiles, num_cpus=0.2, memory=128 * 1024**2) +tiles = tiles.repartition(target_num_rows_per_block=128) + +tiles_with_pixels = tiles.with_column( + "tile", + read_slide_tiles( + col("path"), + col("tile_x"), + col("tile_y"), + col("tile_extent_x"), + col("tile_extent_y"), + col("level"), + ), + num_cpus=1, + memory=4 * 1024**3, +) + +tissue_tiles = tiles_with_pixels.filter(lambda row: row["tile"].std() > 8) +tissue_tiles.write_parquet("tiles") +``` + +??? info "Under the hood" + This pipeline is intentionally staged so that expensive operations happen as late as possible. + + `read_slides` produces one row per slide. + `row_hash` adds a stable identifier. + `flat_map` with `grid_tiles` explodes each slide into many metadata-only tile rows. + `repartition` then redistributes those rows so later pixel reads do not remain concentrated in the same original block. + + Only after that does `read_slide_tiles` open slide files and decode image regions. + Internally, the reader groups rows by slide path so a batch can reuse the same backend handle and avoid unnecessary reopen overhead. + + The final filter is just one example of a metadata-plus-pixels decision rule. + In a production pipeline, you can replace it with overlay coverage, annotation coverage, or a model-based triage stage. + +## Why This Pipeline Works Well + +- metadata stays compact until tile pixels are required, +- slide reads are grouped by source path inside the tile reader, +- and Ray can schedule different stages with different resource hints. + +## Useful Variants + +- replace the simple standard-deviation filter with an overlay-based tissue mask, +- switch from `mpp` to `level` if your workflow is pyramid-level specific, +- or persist the slide-level metadata table separately for reproducibility. + +??? info "Why the metadata table is worth keeping" + Persisting the slide-level table separates slide discovery and level resolution from downstream tile extraction. + That gives you a reproducible snapshot of exactly which files, scales, and tile settings were used to generate a dataset. + + It also makes later pipeline reruns cheaper because you can restart from normalized slide metadata instead of repeating the initial scan over raw slide files. + +## Related API + +- [`ratiopath.ray.read_slides`](../../reference/ray/read_slides.md) +- [`ratiopath.tiling.tilers`](../../reference/tiling/tilers.md) +- [`ratiopath.tiling.read_slide_tiles`](../../reference/tiling/read_slide_tile.md) +- [`ratiopath.tiling.utils`](../../reference/tiling/utils.md) diff --git a/docs/examples/pipelines/mask-first-tile-filtering-pipeline.md b/docs/examples/pipelines/mask-first-tile-filtering-pipeline.md new file mode 100644 index 0000000..591d675 --- /dev/null +++ b/docs/examples/pipelines/mask-first-tile-filtering-pipeline.md @@ -0,0 +1,163 @@ +# Build A Mask-First Tile Filtering Pipeline + +!!! abstract "Overview" + **Problem solved:** generate tissue-mask overlays once, reuse them across distributed tiling runs, and keep the expensive RGB tile reads for only the tiles that pass mask-based filtering. + + **Use this pipeline when:** + + - you do not already have tissue-mask overlays, + - you want reusable mask artifacts, + - and background filtering should happen before large-scale RGB tile decoding. + +## Workflow + +1. Generate one tissue mask per slide. +2. Save the masks as aligned TIFF overlays. +3. Build the slide metadata table with `read_slides`. +4. Expand slides into tile coordinates with `grid_tiles`. +5. Attach mask overlap with `tile_overlay_overlap`. +6. Keep only tiles whose tissue fraction passes your threshold. +7. Optionally read RGB pixels only for the retained tiles. + +## Example + +```python +from pathlib import Path +from typing import Any + +import pyvips +from ray.data.expressions import col +from shapely.geometry import box + +from ratiopath.masks import tissue_mask +from ratiopath.ray import read_slides +from ratiopath.tiling import grid_tiles, read_slide_tiles, tile_overlay_overlap +from ratiopath.tiling.utils import row_hash + +mask_dir = Path("masks") +mask_dir.mkdir(exist_ok=True) +slide_paths = [ + Path("data/slide_a.svs"), + Path("data/slide_b.ome.tif"), +] + + +def generate_mask(slide_path: str) -> str: + slide = pyvips.Image.new_from_file(slide_path, access="sequential") + mask = tissue_mask(slide=slide, mpp=(8.0, 8.0)) + mask_path = mask_dir / f"{Path(slide_path).stem}_tissue_mask.tiff" + mask.tiffsave( + str(mask_path), + tile=True, + pyramid=True, + bigtiff=True, + compression="lzw", + ) + return str(mask_path) + + +def add_mask_path(row: dict[str, Any]) -> dict[str, Any]: + row["mask_path"] = str(mask_dir / f"{Path(row['path']).stem}_tissue_mask.tiff") + return row + + +def expand_tiles(row: dict[str, Any]) -> list[dict[str, Any]]: + return [ + { + "path": row["path"], + "mask_path": row["mask_path"], + "slide_id": row["id"], + "tile_x": x, + "tile_y": y, + "level": row["level"], + "tile_extent_x": row["tile_extent_x"], + "tile_extent_y": row["tile_extent_y"], + "mpp_x": row["mpp_x"], + "mpp_y": row["mpp_y"], + } + for x, y in grid_tiles( + slide_extent=(row["extent_x"], row["extent_y"]), + tile_extent=(row["tile_extent_x"], row["tile_extent_y"]), + stride=(row["stride_x"], row["stride_y"]), + last="keep", + ) + ] + + +for slide_path in slide_paths: + generate_mask(str(slide_path)) + +slides = read_slides( + [str(path) for path in slide_paths], + mpp=0.5, + tile_extent=512, + stride=512, +) +slides = slides.map(row_hash).map(add_mask_path) + +tiles = slides.flat_map(expand_tiles).repartition(target_num_rows_per_block=128) + +roi = box(0, 0, 512, 512) + +tiles = tiles.with_column( + "tissue_overlap", + tile_overlay_overlap( + roi=roi, + overlay_path=col("mask_path"), + tile_x=col("tile_x"), + tile_y=col("tile_y"), + mpp_x=col("mpp_x"), + mpp_y=col("mpp_y"), + ), + num_cpus=1, + memory=4 * 1024**3, +) + +tiles = tiles.map( + lambda row: {**row, "tissue_fraction": row["tissue_overlap"].get("255", 0.0)} +) +tiles = tiles.filter(lambda row: row["tissue_fraction"] >= 0.5) + +tiles = tiles.with_column( + "tile", + read_slide_tiles( + col("path"), + col("tile_x"), + col("tile_y"), + col("tile_extent_x"), + col("tile_extent_y"), + col("level"), + ), + num_cpus=1, + memory=4 * 1024**3, +) +``` + +Using one explicit `slide_paths` list keeps mask generation and slide ingestion aligned. +If you replace this with your own discovery logic, feed the same resolved paths into both stages. + +??? info "Why this pipeline is efficient" + The expensive mask generation happens once per slide and produces a reusable overlay. + After that, later runs can decide which tiles to keep by querying the mask instead of decoding every RGB tile. + + This changes tissue filtering from a repeated image-read problem into a reusable metadata-enrichment problem. + +## When To Use This Instead Of Pixel-Std Filtering + +Use the mask-first path when: + +- you will rerun tiling many times, +- you need a more stable tissue criterion than raw RGB variance, +- or the same masks will also be useful for QC or visualization. + +Use the direct pixel-std filter when: + +- you need a quick first pass, +- the dataset is small, +- or generating masks up front would be unnecessary overhead. + +## Related API + +- [`ratiopath.masks.tissue_mask`](../../reference/masks/tissue_mask.md) +- [`ratiopath.tiling.overlays`](../../reference/tiling/overlays.md) +- [`ratiopath.tiling.read_slide_tiles`](../../reference/tiling/read_slide_tile.md) diff --git a/docs/examples/pipelines/prepare-a-training-dataset-with-leakage-safe-splits.md b/docs/examples/pipelines/prepare-a-training-dataset-with-leakage-safe-splits.md new file mode 100644 index 0000000..80d8cee --- /dev/null +++ b/docs/examples/pipelines/prepare-a-training-dataset-with-leakage-safe-splits.md @@ -0,0 +1,126 @@ +# Prepare A Training Dataset With Leakage-Safe Splits + +!!! abstract "Overview" + **Problem solved:** turn slides into train and test tile datasets while preserving class balance and preventing patient- or case-level leakage. + + **Use this pipeline when:** + + - each patient or case contributes multiple tiles, + - you need train and test sets derived from the same raw slide corpus, + - and you want the split decision to happen before tile extraction scales out. + +## Workflow + +1. Read slide metadata with `read_slides`. +2. Add stable slide identifiers plus patient and label metadata. +3. Split slides with `train_test_split(..., stratify=..., groups=...)`. +4. Build one tile pipeline per split. +5. Optionally attach annotation or overlay signals. +6. Save the resulting train and test tile datasets separately. + +{ align=center } +*Split the slide table first, then build per-split tile datasets so every derived tile inherits a leak-safe parent assignment.* + +## Example + +```python +from typing import Any + +import numpy as np +from ray.data.expressions import col + +from ratiopath.model_selection import train_test_split +from ratiopath.ray import read_slides +from ratiopath.tiling import grid_tiles, read_slide_tiles +from ratiopath.tiling.utils import row_hash + + +def add_cohort_metadata(row: dict[str, Any]) -> dict[str, Any]: + row["patient_id"] = row["path"].split("/")[-1].split("_")[0] + row["label"] = 1 if "tumor" in row["path"] else 0 + return row + + +def expand_tiles(row: dict[str, Any]) -> list[dict[str, Any]]: + return [ + { + "slide_id": row["id"], + "patient_id": row["patient_id"], + "label": row["label"], + "path": row["path"], + "tile_x": x, + "tile_y": y, + "level": row["level"], + "tile_extent_x": row["tile_extent_x"], + "tile_extent_y": row["tile_extent_y"], + } + for x, y in grid_tiles( + slide_extent=(row["extent_x"], row["extent_y"]), + tile_extent=(row["tile_extent_x"], row["tile_extent_y"]), + stride=(row["stride_x"], row["stride_y"]), + last="keep", + ) + ] + + +def build_tiles(split_slides): + tiles = split_slides.flat_map(expand_tiles).repartition(target_num_rows_per_block=128) + tiles = tiles.with_column( + "tile", + read_slide_tiles( + col("path"), + col("tile_x"), + col("tile_y"), + col("tile_extent_x"), + col("tile_extent_y"), + col("level"), + ), + num_cpus=1, + memory=4 * 1024**3, + ) + return tiles.filter(lambda row: row["tile"].std() > 8) + + +slides = read_slides("data", mpp=0.5, tile_extent=512, stride=512) +slides = slides.map(row_hash).map(add_cohort_metadata) + +slide_table = slides.to_pandas() + +train_slide_ids, test_slide_ids = train_test_split( + slide_table["id"].to_numpy(), + test_size=0.2, + random_state=42, + stratify=slide_table["label"].to_numpy(), + groups=slide_table["patient_id"].to_numpy(), +) + +train_ids = set(np.asarray(train_slide_ids).tolist()) +test_ids = set(np.asarray(test_slide_ids).tolist()) + +train_slides = slides.filter(lambda row: row["id"] in train_ids) +test_slides = slides.filter(lambda row: row["id"] in test_ids) + +train_tiles = build_tiles(train_slides) +test_tiles = build_tiles(test_slides) + +train_tiles.write_parquet("train_tiles") +test_tiles.write_parquet("test_tiles") +``` + +??? info "Why the split happens at the slide table" + The leakage decision should happen before you explode slides into thousands of tile rows. + If you split after tiling, it becomes too easy for related tiles from the same patient or slide to land on both sides of the boundary. + + Splitting slide metadata first keeps the grouping logic simple and ensures every downstream tile inherits a leak-safe parent assignment. + +## Useful Variants + +- add a validation split by running the grouped split again on the training slides, +- enrich the split-specific tiles with `tile_annotations` or `tile_overlay_overlap`, +- or save only metadata first and defer `read_slide_tiles` to a later training loader. + +## Related API + +- [`ratiopath.model_selection.split`](../../reference/model_selection/split.md) +- [`ratiopath.ray.read_slides`](../../reference/ray/read_slides.md) +- [`ratiopath.tiling.tilers`](../../reference/tiling/tilers.md) diff --git a/docs/index.md b/docs/index.md index 4c3b6ce..38b86d8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,4 +3,11 @@ Welcome to the RatioPath documentation! This is your go-to resource for understanding and utilizing the RatioPath library for histopathology tasks. Whether you're a researcher, clinician, or developer, you'll find valuable information here to help you get started and make the most of our tools. [Learn](learn/get-started/quick-start/index.md){ .md-button .md-button--primary } -[API Reference](reference/tiling/annotations.md){ .md-button } \ No newline at end of file +[Examples](examples/index.md){ .md-button } +[API Reference](reference/tiling/annotations.md){ .md-button } + +## How To Use These Docs + +- Use [Examples](examples/index.md) when you want to solve a concrete problem. +- Use [Installation](learn/get-started/installation.md) when you need environment setup. +- Use [API Reference](reference/tiling/annotations.md) when you already know which function or class you need. diff --git a/docs/reference/masks/tissue_mask.md b/docs/reference/masks/tissue_mask.md new file mode 100644 index 0000000..1ca8343 --- /dev/null +++ b/docs/reference/masks/tissue_mask.md @@ -0,0 +1,3 @@ +# ratiopath.masks.tissue_mask + +::: ratiopath.masks.tissue_mask diff --git a/docs/reference/model_selection/split.md b/docs/reference/model_selection/split.md new file mode 100644 index 0000000..8529b39 --- /dev/null +++ b/docs/reference/model_selection/split.md @@ -0,0 +1,3 @@ +# ratiopath.model_selection.split + +::: ratiopath.model_selection.split diff --git a/mkdocs.yml b/mkdocs.yml index 4474ea3..9352d87 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,6 +12,24 @@ nav: - learn/get-started/quick-start/overlays.md - learn/get-started/quick-start/tiled-dataset.md - learn/get-started/installation.md + - Examples: + - Overview: examples/index.md + - Basic Functionalities: + - Read Slides And Choose Resolution: examples/basic-functionalities/slide-metadata-and-resolution.md + - Inspect Slides And Preview Tiles: examples/basic-functionalities/inspect-slides-and-preview-tiles.md + - Generate Tile Coordinates: examples/basic-functionalities/tile-grid-generation.md + - Extract Tiles Locally Without Ray: examples/basic-functionalities/extract-tiles-locally-without-ray.md + - Add Annotation And Overlay Signals: examples/basic-functionalities/annotation-and-overlay-enrichment.md + - Split Data With Groups: examples/basic-functionalities/group-aware-splitting.md + - Generate A Tissue Mask Overlay: examples/basic-functionalities/tissue-mask-generation.md + - Aggregate Tensor Columns In Ray: examples/basic-functionalities/tensor-aggregations.md + - Apply Stain Augmentation: examples/basic-functionalities/stain-augmentation.md + - Write TIFF Outputs From Ray: examples/basic-functionalities/write-tiff-outputs-from-ray.md + - Pipelines: + - Build A Distributed Tiling Pipeline: examples/pipelines/distributed-tiling-pipeline.md + - Create An Annotation-Aware Tile Dataset: examples/pipelines/annotation-aware-dataset-pipeline.md + - Build A Mask-First Tile Filtering Pipeline: examples/pipelines/mask-first-tile-filtering-pipeline.md + - Prepare A Training Dataset With Leakage-Safe Splits: examples/pipelines/prepare-a-training-dataset-with-leakage-safe-splits.md - Reference: - Tiling: - annotations: reference/tiling/annotations.md @@ -21,10 +39,13 @@ nav: - utils: reference/tiling/utils.md - Masks: - mask_builders: reference/masks/mask_builders.md + - tissue_mask: reference/masks/tissue_mask.md + - Model Selection: + - split: reference/model_selection/split.md - Parsers: - ASAPParser: reference/parsers/asap.md - - GeoJSONParser: reference/parsers/geojson.md - Darwin7JSONParser: reference/parsers/darwin.md + - GeoJSONParser: reference/parsers/geojson.md - Augmentations: - estimate_stain_vectors: reference/augmentations/estimate_stain_vectors.md - StainAugmentor: reference/augmentations/stain_augmentor.md diff --git a/ratiopath/parsers/geojson_parser.py b/ratiopath/parsers/geojson_parser.py index e094bbe..a7ebad7 100644 --- a/ratiopath/parsers/geojson_parser.py +++ b/ratiopath/parsers/geojson_parser.py @@ -14,26 +14,49 @@ class GeoJSONParser: GeoJSON is a format for encoding geographic data structures using JSON. This parser supports both polygon and point geometries. - Extended capabilities: - - Relational metadata integration: Maps properties from geometry-less definition - features to spatial annotation features via a shared join key (solve_relations). + Extended capabilities include relational metadata integration, which maps + properties from geometry-less definition features to spatial annotation + features via a shared join key (solve_relations). Expected relational schema for solve_relations: - FeatureCollection - ├── Feature (Definition) - │ ├── geometry: null - │ └── properties - │ ├── presetID: "a376..." <──────┐ (join_key) - │ └── meta: { "category": { "name": "Category", "value": "Healthy Tissue" } } - └── Feature (Annotation) │ - ├── geometry: { "type": "Polygon" } │ - └── properties │ - └── presetID: "a376..." <──────┘ + +
| Feature | +Geometry | +Join key | +Metadata | +
|---|---|---|---|
| Definition | +null |
+ properties[join_key], for example presetID |
+ properties.meta values merged into matching annotations |
+
| Annotation | +Spatial GeoJSON geometry, for example Polygon |
+ properties[join_key], for example presetID |
+ Receives metadata from the matching definition feature | +