Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 21 additions & 16 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -159,37 +159,42 @@ Region-level streaming
~~~~~~~~~~~~~~~~~~~~~~~

The encoder-level API above operates on tiles you have already read and
normalized. To extract dense grids over many slide regions at once, slide2vec
provides a higher-level streaming primitive,
``iter_regions_dense`` (from ``slide2vec.runtime.dense_regions``), that wraps
spacing-aware region reads, padding, and encoding into a single generator:
normalized. To extract dense grids over the regions of an hs2p ``TilingResult``,
slide2vec provides a higher-level streaming primitive,
``iter_regions_dense`` (from ``slide2vec.runtime.dense_regions``), that wraps the
region reads, padding, and encoding into a single generator:

.. code-block:: python

from slide2vec.runtime.dense_regions import iter_regions_dense

# ``tiling_result`` is an hs2p TilingResult (from annotation/tissue sampling);
# it already resolved spacing -> level (read_level / read_tile_size_px /
# requested_tile_size_px) and carries the region coordinates and slide path.
for grid in iter_regions_dense(
model=model,
device=model.device,
wsi=wsi,
coordinates=[(0, 0), (4096, 0)],
requested_spacing_um=0.5,
target_size=2048,
tiling_result=tiling_result,
num_workers=4,
):
print(grid.shape) # (d, grid_h, grid_w), float32

For each ``(x, y)`` coordinate (a level-0 top-left location), the region is read
**spacing-aware** at ``requested_spacing_um`` to ``target_size``, run through the
encoder's normalization-only ``get_dense_transform``, padded on the bottom/right
up to the encoder's patch multiple, and encoded into a ``(d, grid_h, grid_w)``
token grid. The ``wsi`` argument is any object exposing
``read_region_at_spacing(location, requested_spacing_um, size, *, tolerance,
interpolation)``, so the loop runs offline in tests with a fake reader.
Reads go through the **same shared batched reader the pooled path uses**
(``WSIRegionReader``, cuCIM ``read_regions(num_workers=…)``). For each region the
reader reads ``read_tile_size_px`` at ``read_level`` and, when that differs from
``requested_tile_size_px``, **area-resizes** to the supervision size reusing
hs2p's own ``resize_array(..., "area")`` — the identical operation the tiling
planner assumed — so the read pixels are unchanged. Each region is then run
through the encoder's normalization-only ``get_dense_transform``, padded on the
bottom/right up to the encoder's patch multiple, and encoded into a
``(d, grid_h, grid_w)`` token grid. The low-level backend is opened lazily via
``slide2vec.data.tile_reader._open_wsi_backend``, so the loop runs offline in
tests by monkeypatching that seam with a fake backend.

Streaming contract:

- Grids are yielded **one per coordinate, in coordinate order**; an empty
``coordinates`` sequence yields nothing.
``tiling_result`` yields nothing.
- Regions are read and encoded one ``batch_size`` chunk at a time, so resident
host memory is bounded by ``batch_size`` rather than by the slide's coordinate
count — there is no per-slide accumulation.
Expand Down
35 changes: 32 additions & 3 deletions slide2vec/data/tile_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from hs2p import TilingResult
from hs2p.utils.stderr import run_with_filtered_stderr
from hs2p.wsi.streaming.plans import build_supertile_index
from hs2p.wsi.wsi import resize_array
from slide2vec.utils.log_utils import suppress_c_stderr


Expand Down Expand Up @@ -278,7 +279,17 @@ def _run_batch():


class WSIRegionReader:
"""Random-access region reader for hierarchical extraction."""
"""Random-access region reader for hierarchical and dense extraction.

Reads ``region_size_px`` at ``read_level`` (batched via cucim ``read_regions`` when the
backend is ``"cucim"``, else serial ``read_region``). When ``resize_to_px`` is set and
differs from ``region_size_px``, each region is resized to ``(resize_to_px, resize_to_px)``
with ``interpolation`` — reusing hs2p's own :func:`resize_array`. This is the geometry
resize the dense path needs (``"area"`` downscale of a coarser-level read to the
supervision tile size), applied identically to what ``read_region_at_spacing`` does, and
left off (``resize_to_px=None``) for the hierarchical unfold path, which needs the full
region.
"""

def __init__(
self,
Expand All @@ -289,13 +300,20 @@ def __init__(
backend: str = "cucim",
num_cucim_workers: int = 4,
gpu_decode: bool = False,
resize_to_px: int | None = None,
interpolation: str = "area",
):
self._image_path = str(image_path)
self._backend = backend
self._num_cucim_workers = num_cucim_workers
self._gpu_decode = gpu_decode
self._read_level = int(read_level)
self._region_size_px = int(region_size_px)
self._resize_to_px = int(resize_to_px) if resize_to_px is not None else None
self._interpolation = interpolation
self._out_size_px = (
self._resize_to_px if self._resize_to_px is not None else self._region_size_px
)
self._reader = None

def _ensure_open(self) -> None:
Expand All @@ -321,13 +339,24 @@ def _read_regions_batch(self, locations: list[tuple[int, int]]) -> list[np.ndarr
for loc in locations
]

def _prepare_region(self, region: np.ndarray) -> np.ndarray:
"""RGB-slice a read region and area-resize it to the target size when configured."""
arr = np.asarray(region)[:, :, :3]
if self._resize_to_px is not None and self._resize_to_px != self._region_size_px:
arr = resize_array(
arr,
(self._resize_to_px, self._resize_to_px),
interpolation=self._interpolation,
)
return arr

def read_batch_with_timing(
self,
locations: list[tuple[int, int]],
) -> tuple[torch.Tensor, dict[str, float]]:
if not locations:
return (
torch.empty((0, 3, self._region_size_px, self._region_size_px), dtype=torch.uint8),
torch.empty((0, 3, self._out_size_px, self._out_size_px), dtype=torch.uint8),
{"reader_open_ms": 0.0, "reader_read_ms": 0.0},
)
stderr_context = suppress_c_stderr() if self._backend == "cucim" else nullcontext()
Expand All @@ -339,7 +368,7 @@ def read_batch_with_timing(
read_start = time.perf_counter()
regions = self._read_regions_batch(locations)
reader_read_ms = (time.perf_counter() - read_start) * 1000.0
batch = np.stack([np.asarray(region)[:, :, :3] for region in regions], axis=0)
batch = np.stack([self._prepare_region(region) for region in regions], axis=0)
tensor = torch.from_numpy(batch).permute(0, 3, 1, 2).contiguous()
return tensor, {"reader_open_ms": reader_open_ms, "reader_read_ms": reader_read_ms}

Expand Down
4 changes: 2 additions & 2 deletions slide2vec/inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -600,7 +600,7 @@ def embed_tiles(
tiling_result=tiling_result,
image_path=slide.image_path,
mask_path=slide.mask_path,
backend=tiling.resolve_slide_backend(resolved_preprocessing, tiling_result),
backend=tiling.resolve_slide_backend(resolved_preprocessing.backend, tiling_result),
preprocessing=resolved_preprocessing,
),
annotation=embedding.tiling_result_annotation(tiling_result),
Expand All @@ -620,7 +620,7 @@ def embed_tiles(
image_path=slide.image_path,
mask_path=slide.mask_path,
tile_size_lv0=int(tiling_result.tile_size_lv0),
backend=tiling.resolve_slide_backend(resolved_preprocessing, tiling_result),
backend=tiling.resolve_slide_backend(resolved_preprocessing.backend, tiling_result),
)
artifact = embedding.write_tile_embedding_artifact(
slide.sample_id,
Expand Down
2 changes: 1 addition & 1 deletion slide2vec/runtime/cpu_budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def log_on_the_fly_worker_override_once(
if not preprocessing.on_the_fly or preprocessing.read_tiles_from is not None:
return
if not any(
resolve_slide_backend(preprocessing, tiling_result) == "cucim"
resolve_slide_backend(preprocessing.backend, tiling_result) == "cucim"
for tiling_result in tiling_results
):
return
Expand Down
Loading
Loading