slide2vec 5.0.1__tar.gz → 5.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {slide2vec-5.0.1 → slide2vec-5.1.1}/PKG-INFO +1 -1
- {slide2vec-5.0.1 → slide2vec-5.1.1}/pyproject.toml +2 -2
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/__init__.py +1 -1
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/dense_regions.py +99 -27
- slide2vec-5.1.1/slide2vec/runtime/dense_sliding.py +185 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec.egg-info/PKG-INFO +1 -1
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec.egg-info/SOURCES.txt +2 -0
- slide2vec-5.1.1/tests/test_dense_regions.py +264 -0
- slide2vec-5.1.1/tests/test_dense_sliding.py +121 -0
- slide2vec-5.0.1/tests/test_dense_regions.py +0 -117
- {slide2vec-5.0.1 → slide2vec-5.1.1}/LICENSE +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/README.md +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/setup.cfg +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/__main__.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/api.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/artifacts.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/cli.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/configs/__init__.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/configs/default.yaml +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/configs/resources.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/data/__init__.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/data/dataset.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/data/tile_reader.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/data/tile_store.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/distributed/__init__.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/distributed/direct_embed_worker.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/distributed/pipeline_worker.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/__init__.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/base.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/__init__.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/conch.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/gigapath.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/hibou.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/hoptimus.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/lunit.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/midnight.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/moozy/__init__.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/moozy/blocks.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/moozy/case.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/moozy/loading.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/moozy/slide.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/moozy/types.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/musk.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/phikon.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/prism.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/prost40m.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/titan.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/uni.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/virchow.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/registry.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/validation.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/inference.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/progress.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/__init__.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/artifacts_collect.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/batching.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/cpu_budget.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/distributed.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/distributed_stage.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/embedding.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/embedding_persist.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/embedding_pipeline.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/hierarchical.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/manifest.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/model_settings.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/patient_pipeline.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/persist_callbacks.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/persistence.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/process_list.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/progress_bridge.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/registry.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/serialization.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/slide_encode.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/tiling.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/tiling_pipeline.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/types.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/worker_io.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/utils/__init__.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/utils/config.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/utils/coordinates.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/utils/log_utils.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/utils/tiling_io.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/utils/utils.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec.egg-info/dependency_links.txt +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec.egg-info/entry_points.txt +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec.egg-info/not-zip-safe +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec.egg-info/requires.txt +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec.egg-info/top_level.txt +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_architecture_runtime_split.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_attention_extraction.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_dense_extraction.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_encoder_registry.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_hs2p_package_cutover.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_output_consistency.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_progress.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_regression_core.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_regression_inference.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_regression_models.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_runtime_batching.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_tile_store.py +0 -0
- {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_tiling_pipeline.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "slide2vec"
|
|
7
|
-
version = "5.
|
|
7
|
+
version = "5.1.1"
|
|
8
8
|
description = "Embedding of whole slide images with Foundation Models"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -167,7 +167,7 @@ no_implicit_reexport = true
|
|
|
167
167
|
max-line-length = 160
|
|
168
168
|
|
|
169
169
|
[tool.bumpver]
|
|
170
|
-
current_version = "5.
|
|
170
|
+
current_version = "5.1.1"
|
|
171
171
|
version_pattern = "MAJOR.MINOR.PATCH"
|
|
172
172
|
commit = false # We do version bumping in CI, not as a commit
|
|
173
173
|
tag = false # Git tag already exists — we don't auto-tag
|
|
@@ -5,7 +5,10 @@ The dense counterpart of the pooled coordinate path (``compute_tile_embeddings_f
|
|
|
5
5
|
each sampled ROI is read **spacing-aware** from the slide, run through the encoder's
|
|
6
6
|
normalization-only dense transform (``get_dense_transform`` — NOT the pooled transform,
|
|
7
7
|
which crops), padded up to the encoder's patch multiple, and encoded via
|
|
8
|
-
``encode_tiles_dense`` into a ``(d, grid_h, grid_w)`` token grid.
|
|
8
|
+
``encode_tiles_dense`` into a ``(d, grid_h, grid_w)`` token grid. ``iter_regions_dense``
|
|
9
|
+
**streams** these grids — yielding one per coordinate, in coordinate order, holding at most
|
|
10
|
+
one ``batch_size`` chunk resident — so host memory is bounded by ``batch_size`` rather than
|
|
11
|
+
by a slide's ROI count.
|
|
9
12
|
|
|
10
13
|
This is the extraction half of soma's slide-manifest segmentation path: slide2vec reads
|
|
11
14
|
regions + encodes (it already owns the region reader and the dense encode); soma sources
|
|
@@ -18,23 +21,49 @@ the finest pyramid level ``<=`` the requested µm/px is read and downscaled to t
|
|
|
18
21
|
the same spacing. The ``wsi`` is injected (any object exposing ``read_region_at_spacing``),
|
|
19
22
|
so the loop is unit-testable offline with a fake reader + a random-weight encoder.
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
Both dense modes run through one primitive (:func:`~slide2vec.runtime.dense_sliding.encode_dense_sliding`):
|
|
25
|
+
``window_size=None`` is a single whole-tile forward (byte-identical to the legacy
|
|
26
|
+
whole-region encode), and a ``window_size`` smaller than the encoded tile slides the
|
|
27
|
+
encoder's native field over the padded tile and blends the per-window token grids with a
|
|
28
|
+
separable raised-cosine map — letting a native-field encoder (e.g. 224-px Virchow2/phikon)
|
|
29
|
+
serve a larger ROI without interpolating its position embeddings.
|
|
24
30
|
"""
|
|
25
31
|
|
|
26
32
|
from __future__ import annotations
|
|
27
33
|
|
|
28
34
|
from dataclasses import dataclass
|
|
29
|
-
from typing import Callable, Sequence
|
|
35
|
+
from typing import Callable, Iterator, Sequence
|
|
30
36
|
|
|
31
37
|
import numpy as np
|
|
32
38
|
import torch
|
|
33
39
|
import torch.nn.functional as F
|
|
34
40
|
from PIL import Image
|
|
35
41
|
|
|
42
|
+
from slide2vec.runtime.batching import autocast_dtype
|
|
43
|
+
from slide2vec.runtime.dense_sliding import encode_dense_sliding
|
|
36
44
|
from slide2vec.runtime.slide_encode import slide_encode_autocast_ctx
|
|
37
45
|
|
|
46
|
+
|
|
47
|
+
def _resolve_output_dtype(output_dtype: "torch.dtype | None", precision: str) -> "torch.dtype":
|
|
48
|
+
"""Resolve the dtype emitted grids are materialized in.
|
|
49
|
+
|
|
50
|
+
Defaults (``output_dtype is None``) to the model's compute precision — fp16 runs
|
|
51
|
+
yield fp16 grids — so the engine no longer force-upcasts everything to float32.
|
|
52
|
+
numpy has no bfloat16, so a bf16 compute precision widens to float32 (its lossless
|
|
53
|
+
container) and an *explicit* ``torch.bfloat16`` request is rejected: the grid is
|
|
54
|
+
materialized via ``.numpy()`` and bfloat16 cannot cross that boundary.
|
|
55
|
+
"""
|
|
56
|
+
if output_dtype is None:
|
|
57
|
+
compute = autocast_dtype(torch, precision) # float16 | bfloat16 | None(fp32)
|
|
58
|
+
return torch.float16 if compute == torch.float16 else torch.float32
|
|
59
|
+
if output_dtype == torch.bfloat16:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
"output_dtype=torch.bfloat16 cannot be materialized as a numpy grid; "
|
|
62
|
+
"request torch.float16 or torch.float32"
|
|
63
|
+
)
|
|
64
|
+
return output_dtype
|
|
65
|
+
|
|
66
|
+
|
|
38
67
|
_PAD_MODES = {"reflect", "constant", "zero", "replicate"}
|
|
39
68
|
|
|
40
69
|
|
|
@@ -136,7 +165,7 @@ def _resolve_encode_fn(
|
|
|
136
165
|
)
|
|
137
166
|
|
|
138
167
|
|
|
139
|
-
def
|
|
168
|
+
def iter_regions_dense(
|
|
140
169
|
*,
|
|
141
170
|
model,
|
|
142
171
|
device: torch.device | str,
|
|
@@ -147,14 +176,23 @@ def encode_regions_dense(
|
|
|
147
176
|
tolerance: float = 0.05,
|
|
148
177
|
pad_mode: str = "reflect",
|
|
149
178
|
image_pad_value: float | None = None,
|
|
179
|
+
window_size: int | None = None,
|
|
180
|
+
overlap: float = 0.0,
|
|
150
181
|
feature_kind: str = "patch_features",
|
|
151
182
|
attention_blocks: tuple[int, ...] = (-1,),
|
|
152
183
|
attention_include_registers: bool = False,
|
|
153
184
|
batch_size: int = 1,
|
|
154
185
|
precision: str = "fp32",
|
|
186
|
+
output_dtype: "torch.dtype | None" = None,
|
|
155
187
|
dense_transform: Callable | None = None,
|
|
156
|
-
) -> np.ndarray:
|
|
157
|
-
"""
|
|
188
|
+
) -> Iterator[np.ndarray]:
|
|
189
|
+
"""Stream slide regions at ``coordinates`` into dense grids, one per coordinate.
|
|
190
|
+
|
|
191
|
+
Yields one ``(d, grid_h, grid_w)`` grid per coordinate, in coordinate order, in the
|
|
192
|
+
model's compute ``precision`` by default (fp16 runs yield fp16 grids; see
|
|
193
|
+
``output_dtype``). Regions are read and encoded one ``batch_size`` chunk at a time, so
|
|
194
|
+
resident host memory is bounded by ``batch_size`` rather than by a slide's ROI count
|
|
195
|
+
(the loop holds at most one batch of grids resident — no per-slide accumulation).
|
|
158
196
|
|
|
159
197
|
Injectable core: takes a constructed dense-capable ``model`` (with
|
|
160
198
|
``encode_tiles_dense`` / ``encode_tiles_attention`` / ``patch_size`` /
|
|
@@ -162,19 +200,38 @@ def encode_regions_dense(
|
|
|
162
200
|
``read_region_at_spacing(location, requested_spacing_um, size, *, tolerance,
|
|
163
201
|
interpolation)``, so it runs offline in tests with random weights + a fake reader.
|
|
164
202
|
|
|
203
|
+
Arguments are validated and geometry is resolved **eagerly** (before any region is
|
|
204
|
+
read): an invalid ``pad_mode`` or ``feature_kind`` raises at the call site, not on the
|
|
205
|
+
first ``next()``. Iteration itself is lazy — reads advance one batch at a time.
|
|
206
|
+
|
|
165
207
|
Args:
|
|
166
208
|
coordinates: ``(x, y)`` top-left locations in **level-0** pixel space (the hs2p
|
|
167
209
|
tiling convention; passed straight to ``read_region_at_spacing``).
|
|
168
210
|
requested_spacing_um: µm/px to read each region at.
|
|
169
211
|
target_size: supervision tile size (int or ``(h, w)``); the region is read at this
|
|
170
212
|
size at ``requested_spacing_um`` and the token grid registers to it.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
213
|
+
window_size: encoder field-of-view chunk fed through the backbone per forward.
|
|
214
|
+
``None`` (default) is one whole-tile forward, byte-identical to the
|
|
215
|
+
whole-region encode; a value smaller than the encoded tile slides the encoder
|
|
216
|
+
over patch-aligned windows and blends the token grids (raised-cosine map). The
|
|
217
|
+
output grid is always the whole geometry's ``(grid_h, grid_w)`` either way —
|
|
218
|
+
sliding is internal to extraction.
|
|
219
|
+
overlap: fractional window overlap in ``[0, 1)`` for the sliding path (ignored when
|
|
220
|
+
``window_size is None``); the stride is ``window * (1 - overlap)``.
|
|
221
|
+
output_dtype: torch dtype the grids are materialized in. ``None`` (default) follows
|
|
222
|
+
the compute ``precision`` — fp16 → fp16, fp32 → fp32, bf16 → fp32 (numpy has no
|
|
223
|
+
bfloat16). Pass e.g. ``torch.float32`` to force a lossless cache regardless of
|
|
224
|
+
precision; an explicit ``torch.bfloat16`` is rejected (cannot cross ``.numpy()``).
|
|
225
|
+
|
|
226
|
+
Yields grids in coordinate order in ``output_dtype``; empty ``coordinates`` yields nothing.
|
|
227
|
+
``feature_kind`` selects ``encode_tiles_dense`` (patch grid) vs
|
|
228
|
+
``encode_tiles_attention`` (CLS-attention grid); both produce a ``(C, gh, gw)`` grid and
|
|
229
|
+
share this path. Each yielded grid is a standalone contiguous copy, so it does not pin
|
|
230
|
+
the rest of its batch's memory alive.
|
|
175
231
|
"""
|
|
176
232
|
if pad_mode not in _PAD_MODES:
|
|
177
233
|
raise ValueError(f"unsupported pad_mode {pad_mode!r}; expected one of {sorted(_PAD_MODES)}")
|
|
234
|
+
resolved_output_dtype = _resolve_output_dtype(output_dtype, precision)
|
|
178
235
|
geometry = compute_dense_geometry(target_size=target_size, patch_size=model.patch_size)
|
|
179
236
|
if dense_transform is None:
|
|
180
237
|
dense_transform = model.get_dense_transform()
|
|
@@ -185,11 +242,8 @@ def encode_regions_dense(
|
|
|
185
242
|
attention_include_registers=attention_include_registers,
|
|
186
243
|
)
|
|
187
244
|
target_h, target_w = geometry.target_size
|
|
188
|
-
|
|
189
245
|
coords = [(int(x), int(y)) for x, y in coordinates]
|
|
190
|
-
|
|
191
|
-
if not coords:
|
|
192
|
-
return np.empty((0, 0, grid_h, grid_w), dtype=np.float32)
|
|
246
|
+
step = max(1, int(batch_size))
|
|
193
247
|
|
|
194
248
|
def _read_padded(location: tuple[int, int]) -> torch.Tensor:
|
|
195
249
|
region = wsi.read_region_at_spacing(
|
|
@@ -215,15 +269,33 @@ def encode_regions_dense(
|
|
|
215
269
|
tensor, geometry, pad_mode=pad_mode, image_pad_value=image_pad_value
|
|
216
270
|
)
|
|
217
271
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
272
|
+
def _stream() -> Iterator[np.ndarray]:
|
|
273
|
+
with torch.inference_mode(), slide_encode_autocast_ctx(device, precision):
|
|
274
|
+
for start in range(0, len(coords), step):
|
|
275
|
+
chunk = coords[start : start + step]
|
|
276
|
+
batch = torch.stack([_read_padded(loc) for loc in chunk]).to(
|
|
277
|
+
device, non_blocking=True
|
|
278
|
+
)
|
|
279
|
+
# Every batch goes through the one windowed primitive: window_size=None
|
|
280
|
+
# short-circuits to a single whole-tile forward (byte-identical to the
|
|
281
|
+
# whole-region encode), so there is no separate whole-region branch.
|
|
282
|
+
out = encode_dense_sliding(
|
|
283
|
+
model,
|
|
284
|
+
batch,
|
|
285
|
+
geometry=geometry,
|
|
286
|
+
window_size=window_size,
|
|
287
|
+
overlap=overlap,
|
|
288
|
+
encode_fn=encode_fn,
|
|
227
289
|
)
|
|
228
|
-
|
|
229
|
-
|
|
290
|
+
if out.ndim != 4:
|
|
291
|
+
raise ValueError(
|
|
292
|
+
f"{feature_kind} encode returned a {out.ndim}-D tensor; expected (B, d, gh, gw)."
|
|
293
|
+
)
|
|
294
|
+
batch_np = out.detach().to(resolved_output_dtype).cpu().numpy()
|
|
295
|
+
for i in range(batch_np.shape[0]):
|
|
296
|
+
# Standalone C-contiguous copy: a per-row view would pin the whole
|
|
297
|
+
# batch alive (the blended sliding output is contiguous, so a view of
|
|
298
|
+
# it would not copy). ``.copy()`` always copies, in C order.
|
|
299
|
+
yield batch_np[i].copy()
|
|
300
|
+
|
|
301
|
+
return _stream()
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Sliding-window dense encoding — ``window_size`` + ``overlap`` as a free knob.
|
|
2
|
+
|
|
3
|
+
The ``whole`` path feeds the full padded tile through the encoder in one forward,
|
|
4
|
+
interpolating the positional embeddings to the larger grid. That is one end of a
|
|
5
|
+
single mechanism; the other end is running the encoder over smaller **windows** and
|
|
6
|
+
stitching the per-window token grids. Three sizes that are usually conflated —
|
|
7
|
+
|
|
8
|
+
* **native size** (e.g. 224) — sets the pos-embed table; not a hard input limit
|
|
9
|
+
(``dynamic_img_size`` lets a ViT process a larger field at the correct mpp);
|
|
10
|
+
* **window size** ``W`` — how big a chunk goes through the ViT in one forward;
|
|
11
|
+
* **input size** — the padded ``encoded_size`` we want dense features for.
|
|
12
|
+
|
|
13
|
+
``whole`` is ``W >= input`` (one window, zero stitching); native sliding is ``W = 224``;
|
|
14
|
+
the useful middle is ``W = 512`` slid over a larger input. So this is **one**
|
|
15
|
+
parametrized path, not a separate mode: :func:`encode_dense_sliding` takes
|
|
16
|
+
``window_size`` (``None`` ⇒ ``whole``) and ``overlap``, and the ``whole`` case falls out
|
|
17
|
+
as the degenerate single window — which we short-circuit to the exact same
|
|
18
|
+
``encode_tiles_dense(batch)`` call, so it stays **byte-identical** to the whole-region
|
|
19
|
+
path (the parity anchor).
|
|
20
|
+
|
|
21
|
+
Stitching happens in **token space** (the grid the decoder/head consume), so the output
|
|
22
|
+
is always ``(B, d, grid_h, grid_w)`` for ``geometry.grid_shape`` regardless of
|
|
23
|
+
``window_size`` — sliding is purely internal to extraction. Windows and strides are kept
|
|
24
|
+
patch-aligned, so each window maps cleanly onto a block of tokens; overlapping windows
|
|
25
|
+
are blended with a separable raised-cosine importance map (the standard frozen-backbone
|
|
26
|
+
dense-inference recipe, cf. MONAI ``sliding_window_inference``) to remove the
|
|
27
|
+
block-boundary seams naive non-overlapping tiling would introduce.
|
|
28
|
+
|
|
29
|
+
Ported from soma's ``soma/dense/sliding.py`` (the window/blend math is encoder
|
|
30
|
+
featurization that belongs in slide2vec); adapted to slide2vec's own
|
|
31
|
+
:class:`~slide2vec.runtime.dense_regions.DenseGridGeometry`.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import math
|
|
37
|
+
from typing import TYPE_CHECKING, Callable
|
|
38
|
+
|
|
39
|
+
import torch
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from slide2vec.runtime.dense_regions import DenseGridGeometry
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"cover_origins",
|
|
46
|
+
"encode_dense_sliding",
|
|
47
|
+
"resolve_window_geometry",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _round_up(value: int, multiple: int) -> int:
|
|
52
|
+
return ((value + multiple - 1) // multiple) * multiple
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _round_to(value: float, multiple: int) -> int:
|
|
56
|
+
return max(multiple, int(round(value / multiple)) * multiple)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cover_origins(extent: int, size: int, stride: int) -> list[int]:
|
|
60
|
+
"""Start offsets of ``size``-wide windows that fully cover ``[0, extent)``.
|
|
61
|
+
|
|
62
|
+
Walks ``[0, extent - size]`` in ``stride`` steps and, if the last step leaves a gap,
|
|
63
|
+
appends one final start flush to the far edge (``extent - size``) so coverage is
|
|
64
|
+
complete with no partial tail. ``extent``/``size``/``stride`` are patch multiples,
|
|
65
|
+
so every start is too — the edge-flush ``extent - size`` is a difference of patch
|
|
66
|
+
multiples.
|
|
67
|
+
"""
|
|
68
|
+
if size >= extent:
|
|
69
|
+
return [0]
|
|
70
|
+
starts = list(range(0, extent - size + 1, stride))
|
|
71
|
+
if starts[-1] + size < extent:
|
|
72
|
+
starts.append(extent - size) # shift the last window flush to the edge
|
|
73
|
+
return starts
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _window_starts(extent: int, win: int, stride: int) -> list[int]:
|
|
77
|
+
"""Patch-aligned encoder-window starts — the token-space use of :func:`cover_origins`."""
|
|
78
|
+
return cover_origins(extent, win, stride)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resolve_window_geometry(
|
|
82
|
+
geometry: DenseGridGeometry, *, window_size: int | None, overlap: float
|
|
83
|
+
) -> tuple[tuple[int, int], tuple[int, int], list[int], list[int]]:
|
|
84
|
+
"""Resolve per-dim window size, stride, and start offsets (all patch-aligned).
|
|
85
|
+
|
|
86
|
+
``window_size`` is rounded up to the patch multiple and clamped to the encoded
|
|
87
|
+
extent; because ``round_up`` is monotonic, ``window_size >= target_size`` always
|
|
88
|
+
clamps to the full extent ⇒ a single window ⇒ the ``whole`` path. ``stride`` is
|
|
89
|
+
``window * (1 - overlap)`` rounded to the patch multiple and clamped to
|
|
90
|
+
``[patch, window]``.
|
|
91
|
+
"""
|
|
92
|
+
enc_h, enc_w = geometry.encoded_size
|
|
93
|
+
ph, pw = geometry.patch_size
|
|
94
|
+
if window_size is None:
|
|
95
|
+
return (enc_h, enc_w), (enc_h, enc_w), [0], [0]
|
|
96
|
+
|
|
97
|
+
win_h = min(_round_up(int(window_size), ph), enc_h)
|
|
98
|
+
win_w = min(_round_up(int(window_size), pw), enc_w)
|
|
99
|
+
keep = 1.0 - float(overlap)
|
|
100
|
+
stride_h = min(win_h, _round_to(win_h * keep, ph))
|
|
101
|
+
stride_w = min(win_w, _round_to(win_w * keep, pw))
|
|
102
|
+
starts_h = _window_starts(enc_h, win_h, stride_h)
|
|
103
|
+
starts_w = _window_starts(enc_w, win_w, stride_w)
|
|
104
|
+
return (win_h, win_w), (stride_h, stride_w), starts_h, starts_w
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _hann_1d(n: int, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
|
|
108
|
+
"""Strictly-positive raised-cosine weights of length ``n`` (uniform if ``n == 1``).
|
|
109
|
+
|
|
110
|
+
``0.5 - 0.5*cos(2*pi*(i+1)/(n+1))`` is > 0 for every ``i in [0, n)`` (no zeros at
|
|
111
|
+
the edges), so the accumulated weight map never hits zero where a window covers.
|
|
112
|
+
"""
|
|
113
|
+
if n <= 1:
|
|
114
|
+
return torch.ones(n, device=device, dtype=dtype)
|
|
115
|
+
i = torch.arange(1, n + 1, device=device, dtype=dtype)
|
|
116
|
+
return 0.5 - 0.5 * torch.cos(2.0 * math.pi * i / (n + 1))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def encode_dense_sliding(
|
|
120
|
+
encoder,
|
|
121
|
+
batch: torch.Tensor,
|
|
122
|
+
*,
|
|
123
|
+
geometry: DenseGridGeometry,
|
|
124
|
+
window_size: int | None,
|
|
125
|
+
overlap: float = 0.0,
|
|
126
|
+
encode_fn: Callable[[torch.Tensor], torch.Tensor] | None = None,
|
|
127
|
+
) -> torch.Tensor:
|
|
128
|
+
"""Encode a padded ``(B, C, enc_h, enc_w)`` batch into ``(B, d, grid_h, grid_w)``.
|
|
129
|
+
|
|
130
|
+
``window_size is None`` (or any window that covers the whole encoded input) is the
|
|
131
|
+
degenerate single-window case: it short-circuits to one full-tile forward,
|
|
132
|
+
byte-identical to the whole-region path. Otherwise the encoder runs over
|
|
133
|
+
patch-aligned overlapping windows and the per-window token grids are blended with a
|
|
134
|
+
separable raised-cosine importance map. The stitch math runs in fp32 (sub-grids are
|
|
135
|
+
upcast before accumulation) so blended regions don't accumulate autocast-dtype error.
|
|
136
|
+
|
|
137
|
+
``encode_fn`` is the per-window encode callable ``(B, C, wh, ww) -> (B, d, th, tw)``;
|
|
138
|
+
it defaults to ``encoder.encode_tiles_dense`` (the patch-feature grid). The attention
|
|
139
|
+
path passes ``encoder.encode_tiles_attention`` (partial-applied with its
|
|
140
|
+
block/register knobs) so a CLS-attention grid stitches through the identical
|
|
141
|
+
raised-cosine blending — the output is just ``(B, K, grid)`` instead of ``(B, d, grid)``.
|
|
142
|
+
"""
|
|
143
|
+
if encode_fn is None:
|
|
144
|
+
encode_fn = encoder.encode_tiles_dense
|
|
145
|
+
(win_h, win_w), _, starts_h, starts_w = resolve_window_geometry(
|
|
146
|
+
geometry, window_size=window_size, overlap=overlap
|
|
147
|
+
)
|
|
148
|
+
if len(starts_h) == 1 and len(starts_w) == 1:
|
|
149
|
+
# Single window == the whole encoded tile: identical forward to the whole-region path.
|
|
150
|
+
return encode_fn(batch)
|
|
151
|
+
|
|
152
|
+
ph, pw = geometry.patch_size
|
|
153
|
+
grid_h, grid_w = geometry.grid_shape
|
|
154
|
+
wth, wtw = win_h // ph, win_w // pw
|
|
155
|
+
# Raised-cosine weights where windows overlap; uniform along any dim that is not
|
|
156
|
+
# actually tiled (a single window there) — avoids needless edge attenuation.
|
|
157
|
+
fdtype = torch.float32
|
|
158
|
+
wh = (
|
|
159
|
+
_hann_1d(wth, batch.device, fdtype)
|
|
160
|
+
if len(starts_h) > 1
|
|
161
|
+
else torch.ones(wth, device=batch.device, dtype=fdtype)
|
|
162
|
+
)
|
|
163
|
+
ww = (
|
|
164
|
+
_hann_1d(wtw, batch.device, fdtype)
|
|
165
|
+
if len(starts_w) > 1
|
|
166
|
+
else torch.ones(wtw, device=batch.device, dtype=fdtype)
|
|
167
|
+
)
|
|
168
|
+
weight = torch.outer(wh, ww) # (wth, wtw)
|
|
169
|
+
|
|
170
|
+
acc: torch.Tensor | None = None
|
|
171
|
+
wsum = torch.zeros(1, 1, grid_h, grid_w, device=batch.device, dtype=fdtype)
|
|
172
|
+
for sh in starts_h:
|
|
173
|
+
th = sh // ph
|
|
174
|
+
for sw in starts_w:
|
|
175
|
+
tw = sw // pw
|
|
176
|
+
window = batch[:, :, sh : sh + win_h, sw : sw + win_w]
|
|
177
|
+
sub = encode_fn(window).to(fdtype) # (B, d, wth, wtw)
|
|
178
|
+
if acc is None:
|
|
179
|
+
acc = torch.zeros(
|
|
180
|
+
sub.shape[0], sub.shape[1], grid_h, grid_w, device=batch.device, dtype=fdtype
|
|
181
|
+
)
|
|
182
|
+
acc[:, :, th : th + wth, tw : tw + wtw] += sub * weight
|
|
183
|
+
wsum[:, :, th : th + wth, tw : tw + wtw] += weight
|
|
184
|
+
assert acc is not None # at least one window always runs
|
|
185
|
+
return acc / wsum
|
|
@@ -54,6 +54,7 @@ slide2vec/runtime/artifacts_collect.py
|
|
|
54
54
|
slide2vec/runtime/batching.py
|
|
55
55
|
slide2vec/runtime/cpu_budget.py
|
|
56
56
|
slide2vec/runtime/dense_regions.py
|
|
57
|
+
slide2vec/runtime/dense_sliding.py
|
|
57
58
|
slide2vec/runtime/distributed.py
|
|
58
59
|
slide2vec/runtime/distributed_stage.py
|
|
59
60
|
slide2vec/runtime/embedding.py
|
|
@@ -84,6 +85,7 @@ tests/test_architecture_runtime_split.py
|
|
|
84
85
|
tests/test_attention_extraction.py
|
|
85
86
|
tests/test_dense_extraction.py
|
|
86
87
|
tests/test_dense_regions.py
|
|
88
|
+
tests/test_dense_sliding.py
|
|
87
89
|
tests/test_encoder_registry.py
|
|
88
90
|
tests/test_hs2p_package_cutover.py
|
|
89
91
|
tests/test_output_consistency.py
|