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.
Files changed (101) hide show
  1. {slide2vec-5.0.1 → slide2vec-5.1.1}/PKG-INFO +1 -1
  2. {slide2vec-5.0.1 → slide2vec-5.1.1}/pyproject.toml +2 -2
  3. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/__init__.py +1 -1
  4. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/dense_regions.py +99 -27
  5. slide2vec-5.1.1/slide2vec/runtime/dense_sliding.py +185 -0
  6. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec.egg-info/PKG-INFO +1 -1
  7. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec.egg-info/SOURCES.txt +2 -0
  8. slide2vec-5.1.1/tests/test_dense_regions.py +264 -0
  9. slide2vec-5.1.1/tests/test_dense_sliding.py +121 -0
  10. slide2vec-5.0.1/tests/test_dense_regions.py +0 -117
  11. {slide2vec-5.0.1 → slide2vec-5.1.1}/LICENSE +0 -0
  12. {slide2vec-5.0.1 → slide2vec-5.1.1}/README.md +0 -0
  13. {slide2vec-5.0.1 → slide2vec-5.1.1}/setup.cfg +0 -0
  14. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/__main__.py +0 -0
  15. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/api.py +0 -0
  16. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/artifacts.py +0 -0
  17. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/cli.py +0 -0
  18. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/configs/__init__.py +0 -0
  19. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/configs/default.yaml +0 -0
  20. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/configs/resources.py +0 -0
  21. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/data/__init__.py +0 -0
  22. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/data/dataset.py +0 -0
  23. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/data/tile_reader.py +0 -0
  24. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/data/tile_store.py +0 -0
  25. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/distributed/__init__.py +0 -0
  26. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/distributed/direct_embed_worker.py +0 -0
  27. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/distributed/pipeline_worker.py +0 -0
  28. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/__init__.py +0 -0
  29. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/base.py +0 -0
  30. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/__init__.py +0 -0
  31. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/conch.py +0 -0
  32. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/gigapath.py +0 -0
  33. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/hibou.py +0 -0
  34. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/hoptimus.py +0 -0
  35. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/lunit.py +0 -0
  36. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/midnight.py +0 -0
  37. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/moozy/__init__.py +0 -0
  38. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/moozy/blocks.py +0 -0
  39. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/moozy/case.py +0 -0
  40. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/moozy/loading.py +0 -0
  41. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/moozy/slide.py +0 -0
  42. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/moozy/types.py +0 -0
  43. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/musk.py +0 -0
  44. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/phikon.py +0 -0
  45. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/prism.py +0 -0
  46. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/prost40m.py +0 -0
  47. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/titan.py +0 -0
  48. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/uni.py +0 -0
  49. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/models/virchow.py +0 -0
  50. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/registry.py +0 -0
  51. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/encoders/validation.py +0 -0
  52. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/inference.py +0 -0
  53. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/progress.py +0 -0
  54. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/__init__.py +0 -0
  55. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/artifacts_collect.py +0 -0
  56. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/batching.py +0 -0
  57. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/cpu_budget.py +0 -0
  58. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/distributed.py +0 -0
  59. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/distributed_stage.py +0 -0
  60. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/embedding.py +0 -0
  61. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/embedding_persist.py +0 -0
  62. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/embedding_pipeline.py +0 -0
  63. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/hierarchical.py +0 -0
  64. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/manifest.py +0 -0
  65. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/model_settings.py +0 -0
  66. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/patient_pipeline.py +0 -0
  67. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/persist_callbacks.py +0 -0
  68. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/persistence.py +0 -0
  69. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/process_list.py +0 -0
  70. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/progress_bridge.py +0 -0
  71. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/registry.py +0 -0
  72. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/serialization.py +0 -0
  73. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/slide_encode.py +0 -0
  74. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/tiling.py +0 -0
  75. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/tiling_pipeline.py +0 -0
  76. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/types.py +0 -0
  77. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/runtime/worker_io.py +0 -0
  78. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/utils/__init__.py +0 -0
  79. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/utils/config.py +0 -0
  80. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/utils/coordinates.py +0 -0
  81. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/utils/log_utils.py +0 -0
  82. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/utils/tiling_io.py +0 -0
  83. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec/utils/utils.py +0 -0
  84. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec.egg-info/dependency_links.txt +0 -0
  85. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec.egg-info/entry_points.txt +0 -0
  86. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec.egg-info/not-zip-safe +0 -0
  87. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec.egg-info/requires.txt +0 -0
  88. {slide2vec-5.0.1 → slide2vec-5.1.1}/slide2vec.egg-info/top_level.txt +0 -0
  89. {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_architecture_runtime_split.py +0 -0
  90. {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_attention_extraction.py +0 -0
  91. {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_dense_extraction.py +0 -0
  92. {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_encoder_registry.py +0 -0
  93. {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_hs2p_package_cutover.py +0 -0
  94. {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_output_consistency.py +0 -0
  95. {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_progress.py +0 -0
  96. {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_regression_core.py +0 -0
  97. {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_regression_inference.py +0 -0
  98. {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_regression_models.py +0 -0
  99. {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_runtime_batching.py +0 -0
  100. {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_tile_store.py +0 -0
  101. {slide2vec-5.0.1 → slide2vec-5.1.1}/tests/test_tiling_pipeline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slide2vec
3
- Version: 5.0.1
3
+ Version: 5.1.1
4
4
  Summary: Embedding of whole slide images with Foundation Models
5
5
  Author-email: Clément Grisi <clement.grisi@radboudumc.nl>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "slide2vec"
7
- version = "5.0.1"
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.0.1"
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
@@ -11,7 +11,7 @@ from slide2vec.api import (
11
11
  from slide2vec.artifacts import HierarchicalEmbeddingArtifact, SlideEmbeddingArtifact, TileEmbeddingArtifact
12
12
 
13
13
 
14
- __version__ = "5.0.1"
14
+ __version__ = "5.1.1"
15
15
 
16
16
  __all__ = [
17
17
  "Model",
@@ -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
- Whole-tile only (one padded forward per region). Sliding-window dense extraction over
22
- coordinates (``window_size`` < input) is a deferred follow-up — large ROIs that exceed the
23
- encoder's comfortable field are out of scope for the first increment.
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 encode_regions_dense(
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
- """Encode slide regions at ``coordinates`` into dense grids; return ``(N, d, gh, gw)``.
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
- Returns a ``float32`` array of dense grids in coordinate order. ``feature_kind``
173
- selects ``encode_tiles_dense`` (patch grid) vs ``encode_tiles_attention`` (CLS-attention
174
- grid); both produce a ``(C, gh, gw)`` grid and share this path.
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
- grid_h, grid_w = geometry.grid_shape
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
- grids: list[np.ndarray] = []
219
- with torch.inference_mode(), slide_encode_autocast_ctx(device, precision):
220
- for start in range(0, len(coords), max(1, int(batch_size))):
221
- chunk = coords[start : start + max(1, int(batch_size))]
222
- batch = torch.stack([_read_padded(loc) for loc in chunk]).to(device, non_blocking=True)
223
- out = encode_fn(batch)
224
- if out.ndim != 4:
225
- raise ValueError(
226
- f"{feature_kind} encode returned a {out.ndim}-D tensor; expected (B, d, gh, gw)."
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
- grids.append(out.detach().float().cpu().numpy())
229
- return np.concatenate(grids, axis=0)
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slide2vec
3
- Version: 5.0.1
3
+ Version: 5.1.1
4
4
  Summary: Embedding of whole slide images with Foundation Models
5
5
  Author-email: Clément Grisi <clement.grisi@radboudumc.nl>
6
6
  License-Expression: Apache-2.0
@@ -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