slide2vec 5.3.0__tar.gz → 5.4.0__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 (119) hide show
  1. {slide2vec-5.3.0 → slide2vec-5.4.0}/PKG-INFO +3 -3
  2. {slide2vec-5.3.0 → slide2vec-5.4.0}/pyproject.toml +4 -4
  3. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/__init__.py +12 -2
  4. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/api.py +129 -2
  5. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/artifacts.py +119 -2
  6. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/configs/default.yaml +1 -0
  7. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/data/tile_reader.py +88 -12
  8. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/distributed/__init__.py +34 -66
  9. slide2vec-5.4.0/slide2vec/distributed/dense_worker.py +98 -0
  10. slide2vec-5.4.0/slide2vec/distributed/direct_embed_worker.py +194 -0
  11. slide2vec-5.4.0/slide2vec/distributed/pipeline_worker.py +111 -0
  12. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/base.py +15 -13
  13. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/conch.py +4 -2
  14. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/dinov2.py +1 -0
  15. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/genbio.py +3 -2
  16. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/gigapath.py +7 -1
  17. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/gpfm.py +1 -0
  18. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/hibou.py +3 -1
  19. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/hoptimus.py +19 -1
  20. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/lunit.py +1 -0
  21. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/midnight.py +2 -1
  22. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/mstar.py +1 -0
  23. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/musk.py +2 -1
  24. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/phikon.py +4 -2
  25. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/prost40m.py +1 -0
  26. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/uni.py +2 -0
  27. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/virchow.py +34 -5
  28. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/registry.py +34 -0
  29. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/inference.py +45 -6
  30. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/progress.py +41 -4
  31. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/batching.py +26 -0
  32. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/cpu_budget.py +1 -1
  33. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/dense_regions.py +88 -51
  34. slide2vec-5.4.0/slide2vec/runtime/dense_shard.py +314 -0
  35. slide2vec-5.4.0/slide2vec/runtime/dense_stage.py +299 -0
  36. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/distributed.py +15 -0
  37. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/distributed_stage.py +5 -0
  38. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/embedding.py +21 -0
  39. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/embedding_persist.py +8 -3
  40. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/embedding_pipeline.py +12 -6
  41. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/patient_pipeline.py +2 -1
  42. slide2vec-5.4.0/slide2vec/runtime/pooled_encoder_input.py +102 -0
  43. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/process_list.py +2 -2
  44. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/progress_bridge.py +1 -0
  45. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/serialization.py +50 -1
  46. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/tiling.py +7 -4
  47. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/types.py +1 -0
  48. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/utils/config.py +9 -9
  49. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/utils/tiling_io.py +11 -0
  50. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec.egg-info/PKG-INFO +3 -3
  51. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec.egg-info/SOURCES.txt +9 -0
  52. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec.egg-info/requires.txt +2 -2
  53. {slide2vec-5.3.0 → slide2vec-5.4.0}/tests/test_dense_extraction.py +6 -6
  54. slide2vec-5.4.0/tests/test_dense_regions.py +450 -0
  55. slide2vec-5.4.0/tests/test_dense_shard.py +453 -0
  56. slide2vec-5.4.0/tests/test_dense_stage.py +274 -0
  57. slide2vec-5.4.0/tests/test_dense_worker.py +94 -0
  58. {slide2vec-5.3.0 → slide2vec-5.4.0}/tests/test_hs2p_package_cutover.py +32 -3
  59. {slide2vec-5.3.0 → slide2vec-5.4.0}/tests/test_output_consistency.py +12 -65
  60. slide2vec-5.4.0/tests/test_pooled_encoder_input.py +513 -0
  61. slide2vec-5.4.0/tests/test_pooled_geometry.py +279 -0
  62. {slide2vec-5.3.0 → slide2vec-5.4.0}/tests/test_progress.py +168 -0
  63. {slide2vec-5.3.0 → slide2vec-5.4.0}/tests/test_regression_core.py +61 -3
  64. {slide2vec-5.3.0 → slide2vec-5.4.0}/tests/test_regression_inference.py +87 -57
  65. {slide2vec-5.3.0 → slide2vec-5.4.0}/tests/test_regression_models.py +5 -5
  66. slide2vec-5.3.0/slide2vec/distributed/direct_embed_worker.py +0 -192
  67. slide2vec-5.3.0/slide2vec/distributed/pipeline_worker.py +0 -113
  68. slide2vec-5.3.0/tests/test_dense_regions.py +0 -264
  69. {slide2vec-5.3.0 → slide2vec-5.4.0}/LICENSE +0 -0
  70. {slide2vec-5.3.0 → slide2vec-5.4.0}/README.md +0 -0
  71. {slide2vec-5.3.0 → slide2vec-5.4.0}/setup.cfg +0 -0
  72. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/__main__.py +0 -0
  73. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/cli.py +0 -0
  74. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/configs/__init__.py +0 -0
  75. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/configs/resources.py +0 -0
  76. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/data/__init__.py +0 -0
  77. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/data/dataset.py +0 -0
  78. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/data/tile_store.py +0 -0
  79. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/__init__.py +0 -0
  80. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/__init__.py +0 -0
  81. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/moozy/__init__.py +0 -0
  82. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/moozy/blocks.py +0 -0
  83. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/moozy/case.py +0 -0
  84. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/moozy/loading.py +0 -0
  85. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/moozy/slide.py +0 -0
  86. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/moozy/types.py +0 -0
  87. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/prism.py +0 -0
  88. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/models/titan.py +0 -0
  89. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/encoders/validation.py +0 -0
  90. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/__init__.py +0 -0
  91. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/artifacts_collect.py +0 -0
  92. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/dense_sliding.py +0 -0
  93. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/hierarchical.py +0 -0
  94. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/manifest.py +0 -0
  95. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/model_settings.py +0 -0
  96. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/persist_callbacks.py +0 -0
  97. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/persistence.py +0 -0
  98. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/registry.py +0 -0
  99. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/slide_encode.py +0 -0
  100. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/tiling_pipeline.py +0 -0
  101. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/runtime/worker_io.py +0 -0
  102. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/utils/__init__.py +0 -0
  103. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/utils/coordinates.py +0 -0
  104. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/utils/log_utils.py +0 -0
  105. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec/utils/utils.py +0 -0
  106. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec.egg-info/dependency_links.txt +0 -0
  107. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec.egg-info/entry_points.txt +0 -0
  108. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec.egg-info/not-zip-safe +0 -0
  109. {slide2vec-5.3.0 → slide2vec-5.4.0}/slide2vec.egg-info/top_level.txt +0 -0
  110. {slide2vec-5.3.0 → slide2vec-5.4.0}/tests/test_architecture_runtime_split.py +0 -0
  111. {slide2vec-5.3.0 → slide2vec-5.4.0}/tests/test_attention_extraction.py +0 -0
  112. {slide2vec-5.3.0 → slide2vec-5.4.0}/tests/test_dense_sliding.py +0 -0
  113. {slide2vec-5.3.0 → slide2vec-5.4.0}/tests/test_dinov2_natimage.py +0 -0
  114. {slide2vec-5.3.0 → slide2vec-5.4.0}/tests/test_encoder_registry.py +0 -0
  115. {slide2vec-5.3.0 → slide2vec-5.4.0}/tests/test_gpfm_genbio_heavy.py +0 -0
  116. {slide2vec-5.3.0 → slide2vec-5.4.0}/tests/test_patch_size_metadata.py +0 -0
  117. {slide2vec-5.3.0 → slide2vec-5.4.0}/tests/test_runtime_batching.py +0 -0
  118. {slide2vec-5.3.0 → slide2vec-5.4.0}/tests/test_tile_store.py +0 -0
  119. {slide2vec-5.3.0 → slide2vec-5.4.0}/tests/test_tiling_pipeline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slide2vec
3
- Version: 5.3.0
3
+ Version: 5.4.0
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
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.13
15
15
  Requires-Python: >=3.10
16
16
  Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
- Requires-Dist: hs2p[asap,cucim,openslide,sam2,vips]>=4.2.0
18
+ Requires-Dist: hs2p[asap,cucim,openslide,sam2,vips]>=4.3.0
19
19
  Requires-Dist: omegaconf
20
20
  Requires-Dist: matplotlib
21
21
  Requires-Dist: numpy<2
@@ -65,7 +65,7 @@ Requires-Dist: numpy<2; extra == "fm"
65
65
  Requires-Dist: pandas; extra == "fm"
66
66
  Requires-Dist: pillow; extra == "fm"
67
67
  Requires-Dist: rich; extra == "fm"
68
- Requires-Dist: hs2p[asap,cucim,openslide,sam2,vips]>=4.2.0; extra == "fm"
68
+ Requires-Dist: hs2p[asap,cucim,openslide,sam2,vips]>=4.3.0; extra == "fm"
69
69
  Requires-Dist: wandb; extra == "fm"
70
70
  Requires-Dist: torch<2.8,>=2.3; extra == "fm"
71
71
  Requires-Dist: torchvision>=0.18.0; extra == "fm"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "slide2vec"
7
- version = "5.3.0"
7
+ version = "5.4.0"
8
8
  description = "Embedding of whole slide images with Foundation Models"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -21,7 +21,7 @@ classifiers = [
21
21
  "Programming Language :: Python :: 3.13",
22
22
  ]
23
23
  dependencies = [
24
- "hs2p[asap,cucim,openslide,sam2,vips]>=4.2.0",
24
+ "hs2p[asap,cucim,openslide,sam2,vips]>=4.3.0",
25
25
  "omegaconf",
26
26
  "matplotlib",
27
27
  "numpy<2",
@@ -88,7 +88,7 @@ fm = [
88
88
  "pandas",
89
89
  "pillow",
90
90
  "rich",
91
- "hs2p[asap,cucim,openslide,sam2,vips]>=4.2.0",
91
+ "hs2p[asap,cucim,openslide,sam2,vips]>=4.3.0",
92
92
  "wandb",
93
93
  "torch>=2.3,<2.8",
94
94
  "torchvision>=0.18.0",
@@ -167,7 +167,7 @@ no_implicit_reexport = true
167
167
  max-line-length = 160
168
168
 
169
169
  [tool.bumpver]
170
- current_version = "5.3.0"
170
+ current_version = "5.4.0"
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
@@ -1,4 +1,5 @@
1
1
  from slide2vec.api import (
2
+ DenseOptions,
2
3
  EmbeddedPatient,
3
4
  EmbeddedSlide,
4
5
  ExecutionOptions,
@@ -6,18 +7,26 @@ from slide2vec.api import (
6
7
  Pipeline,
7
8
  PreprocessingConfig,
8
9
  RunResult,
10
+ SlideRegions,
9
11
  list_models,
10
12
  )
11
- from slide2vec.artifacts import HierarchicalEmbeddingArtifact, SlideEmbeddingArtifact, TileEmbeddingArtifact
13
+ from slide2vec.artifacts import (
14
+ DenseRegionArtifact,
15
+ HierarchicalEmbeddingArtifact,
16
+ SlideEmbeddingArtifact,
17
+ TileEmbeddingArtifact,
18
+ )
12
19
 
13
20
 
14
- __version__ = "5.3.0"
21
+ __version__ = "5.4.0"
15
22
 
16
23
  __all__ = [
17
24
  "Model",
18
25
  "list_models",
19
26
  "Pipeline",
20
27
  "PreprocessingConfig",
28
+ "DenseOptions",
29
+ "SlideRegions",
21
30
  "ExecutionOptions",
22
31
  "RunResult",
23
32
  "EmbeddedPatient",
@@ -25,5 +34,6 @@ __all__ = [
25
34
  "SlideEmbeddingArtifact",
26
35
  "HierarchicalEmbeddingArtifact",
27
36
  "TileEmbeddingArtifact",
37
+ "DenseRegionArtifact",
28
38
  "__version__",
29
39
  ]
@@ -11,6 +11,7 @@ import torch
11
11
  from hs2p import SlideSpec
12
12
 
13
13
  from slide2vec.artifacts import (
14
+ DenseRegionArtifact,
14
15
  HierarchicalEmbeddingArtifact,
15
16
  PatientEmbeddingArtifact,
16
17
  SlideEmbeddingArtifact,
@@ -28,6 +29,7 @@ from slide2vec.runtime.model_settings import (
28
29
  )
29
30
  from slide2vec.progress import emit_progress
30
31
  from slide2vec.runtime.types import LoadedModel
32
+ from slide2vec.runtime.pooled_encoder_input import PooledEncoderInputPlan
31
33
  from slide2vec.utils.utils import cpu_worker_limit, slurm_cpu_limit
32
34
 
33
35
  PathLike = str | Path
@@ -101,6 +103,13 @@ class PreprocessingConfig:
101
103
  #: Slide reading backend. ``"auto"`` tries cucim → openslide → vips in order.
102
104
  #: Explicit choices: ``"cucim"``, ``"openslide"``, ``"vips"``, ``"asap"``.
103
105
  backend: str = "auto"
106
+ #: Source-mask reading backend, resolved independently from the *mask* path
107
+ #: (hs2p ≥ 4.3.0). ``"auto"`` probes openability just like :attr:`backend`. Set this
108
+ #: explicitly (e.g. ``"openslide"``) when a precomputed tissue or annotation mask needs
109
+ #: a different decoder than its slide — hs2p no longer silently falls back to another
110
+ #: reader, so a mask the slide backend cannot decode fails unless overridden here.
111
+ #: Accepts the same values as :attr:`backend`; ignored for slides with no source mask.
112
+ mask_backend: str = "auto"
104
113
  #: Target spacing in µm/px. Resolved from the model preset when ``None``.
105
114
  requested_spacing_um: float | None = None
106
115
  #: Tile side length in pixels at *requested_spacing_um*.
@@ -181,6 +190,7 @@ class PreprocessingConfig:
181
190
  region_tile_multiple = getattr(tiling.params, "region_tile_multiple", None)
182
191
  return cls(
183
192
  backend=tiling.backend,
193
+ mask_backend=getattr(tiling, "mask_backend", "auto"),
184
194
  requested_spacing_um=float(tiling.params.requested_spacing_um),
185
195
  requested_tile_size_px=int(tiling.params.requested_tile_size_px),
186
196
  requested_region_size_px=int(region_size_px) if region_size_px is not None else None,
@@ -208,6 +218,9 @@ class PreprocessingConfig:
208
218
  def with_backend(self, backend: str) -> "PreprocessingConfig":
209
219
  return replace(self, backend=backend)
210
220
 
221
+ def with_mask_backend(self, mask_backend: str) -> "PreprocessingConfig":
222
+ return replace(self, mask_backend=mask_backend)
223
+
211
224
 
212
225
 
213
226
  @dataclass(frozen=True, kw_only=True)
@@ -312,6 +325,63 @@ class ExecutionOptions:
312
325
  return replace(self, output_dir=Path(output_dir))
313
326
 
314
327
 
328
+ @dataclass(frozen=True, kw_only=True)
329
+ class DenseOptions:
330
+ """Dense ``(d, gh, gw)`` grid extraction settings (issue #217).
331
+
332
+ The dense counterpart of the pooled :class:`PreprocessingConfig`: it names the
333
+ extraction geometry (spacing → level, supervision ``target_size``, padding) and the
334
+ dense encode knobs (whole-tile vs sliding-window, patch grid vs CLS-attention). Unlike
335
+ the pooled path there is no tiling — the caller supplies ROI coordinates directly (see
336
+ :class:`SlideRegions`) — so a ``DenseOptions`` carries only what slide2vec needs to read
337
+ and encode each ROI. ``ExecutionOptions`` is reused unchanged for output/precision/GPUs.
338
+ """
339
+
340
+ #: Target spacing in µm/px the ROI is read at (resolved to a pyramid level per slide).
341
+ spacing_um: float
342
+ #: Supervision tile side length in pixels at *spacing_um* (the dense grid registers to it).
343
+ target_size: int
344
+ #: Relative spacing tolerance for pyramid level selection.
345
+ tolerance: float = 0.05
346
+ #: Slide reading backend. ``"auto"`` resolves per slide (cucim → openslide → vips).
347
+ backend: str = "auto"
348
+ #: Padding mode used to pad the tile up to the encoder's patch multiple.
349
+ #: One of ``"reflect"`` / ``"replicate"`` / ``"constant"`` / ``"zero"``.
350
+ pad_mode: str = "reflect"
351
+ #: Constant fill value for ``pad_mode in {"constant", "zero"}`` (ignored otherwise).
352
+ image_pad_value: float | None = None
353
+ #: Encoder field-of-view chunk fed through the backbone per forward. ``None`` (default)
354
+ #: is one whole-tile forward; a smaller value slides the encoder and blends token grids.
355
+ window_size: int | None = None
356
+ #: Fractional window overlap in ``[0, 1)`` for the sliding path (ignored when
357
+ #: ``window_size is None``).
358
+ overlap: float = 0.0
359
+ #: ``"patch_features"`` (the patch-token grid) or ``"cls_attention"`` (CLS/register
360
+ #: self-attention grid).
361
+ feature_kind: str = "patch_features"
362
+ #: Transformer blocks whose CLS attention is read (``cls_attention`` only).
363
+ attention_blocks: tuple[int, ...] = (-1,)
364
+ #: Include register-token query rows as extra attention channels (``cls_attention`` only).
365
+ attention_include_registers: bool = False
366
+
367
+
368
+ @dataclass(frozen=True, kw_only=True)
369
+ class SlideRegions:
370
+ """One slide's ROIs for dense extraction: ``(sample_id, image_path, coordinates, annotation)``.
371
+
372
+ The dense input unit soma's slide-manifest path hands to
373
+ :meth:`Model.embed_regions_dense`. ``coordinates`` is an ``(N, 2)`` array of level-0
374
+ top-left ``(x, y)`` pixel coordinates; each ROI is read + encoded into one persisted
375
+ ``(d, gh, gw)`` grid named ``<x>_<y>.pt``. ``annotation`` namespaces the output under a
376
+ per-class subdirectory (reusing the pooled convention); ``None`` is the flat layout.
377
+ """
378
+
379
+ sample_id: str
380
+ image_path: PathLike
381
+ coordinates: Any
382
+ annotation: str | None = None
383
+
384
+
315
385
  @dataclass(frozen=True, kw_only=True)
316
386
  class RunResult:
317
387
  """Return value of :meth:`Pipeline.run`."""
@@ -374,6 +444,8 @@ class EmbeddedSlide:
374
444
  tiling_preview_path: Path | None = None
375
445
  #: Encoder latent representations when available; ``None`` otherwise.
376
446
  latents: Any | None = None
447
+ #: Factual square tensor side length immediately before tile encoding.
448
+ encoder_input_size_px: int | None = None
377
449
 
378
450
 
379
451
  class Model:
@@ -391,6 +463,8 @@ class Model:
391
463
  self.allow_non_recommended_settings = bool(allow_non_recommended_settings)
392
464
  self._output_variant = output_variant
393
465
  self._backend: LoadedModel | None = None
466
+ self._pooled_input_plan: PooledEncoderInputPlan | None = None
467
+ self._backend_pooled_input_plan: PooledEncoderInputPlan | None = None
394
468
 
395
469
  @classmethod
396
470
  def from_preset(
@@ -588,8 +662,58 @@ class Model:
588
662
  execution=resolved,
589
663
  )
590
664
 
665
+ def embed_regions_dense(
666
+ self,
667
+ regions: "Sequence[SlideRegions]",
668
+ *,
669
+ dense: "DenseOptions",
670
+ execution: ExecutionOptions | None = None,
671
+ ) -> list[DenseRegionArtifact]:
672
+ """Extract + persist a dense ``(d, gh, gw)`` grid per caller-supplied ROI.
673
+
674
+ The dense counterpart of the pooled coordinate path: each ``SlideRegions`` names a
675
+ slide + a set of level-0 ROI coordinates, and every ROI is read, encoded through the
676
+ dense transform, and written to ``dense_embeddings/[<class>/]<sample_id>/<x>_<y>.pt``
677
+ plus a geometry sidecar. The run splits its ROIs across all visible GPUs
678
+ (``execution.num_gpus``); ``num_gpus=1`` encodes fully in-process. Resume is
679
+ automatic — ROIs whose sidecar already exists are skipped. Returns one
680
+ :class:`~slide2vec.artifacts.DenseRegionArtifact` per input ROI.
681
+ """
682
+ from slide2vec.runtime.dense_stage import embed_regions_dense
683
+
684
+ resolved = _coerce_execution_options(execution, model=self)
685
+ _require_output_dir_for_persistence(resolved, method_name="Model.embed_regions_dense(...)")
686
+ with _auto_progress_reporting(output_dir=resolved.output_dir):
687
+ return embed_regions_dense(self, regions, dense=dense, execution=resolved)
688
+
689
+ def _prepare_pooled_input(
690
+ self,
691
+ preprocessing: PreprocessingConfig,
692
+ *,
693
+ emit_run_info: bool,
694
+ ) -> PooledEncoderInputPlan:
695
+ plan = PooledEncoderInputPlan.resolve(
696
+ self.name,
697
+ requested_tile_size_px=int(preprocessing.requested_tile_size_px),
698
+ allow_non_recommended_settings=self.allow_non_recommended_settings,
699
+ )
700
+ self._pooled_input_plan = plan
701
+ if emit_run_info and plan.requires_variable_model_input:
702
+ logging.getLogger("slide2vec").info(
703
+ "Pooled encoder input for '%s': preset %dpx, requested %dpx, "
704
+ "exact encoder input %dpx; using normalization-only preprocessing.",
705
+ self.name,
706
+ plan.preset_input_size_px,
707
+ plan.requested_tile_size_px,
708
+ plan.expected_encoder_input_size_px,
709
+ )
710
+ return plan
711
+
591
712
  def _load_backend(self) -> LoadedModel:
592
- if self._backend is None:
713
+ if (
714
+ self._backend is None
715
+ or self._backend_pooled_input_plan != self._pooled_input_plan
716
+ ):
593
717
  from slide2vec.inference import load_model
594
718
 
595
719
  emit_progress("model.loading", model_name=self.name)
@@ -597,7 +721,10 @@ class Model:
597
721
  name=self.name,
598
722
  device=self._requested_device,
599
723
  output_variant=self._output_variant,
724
+ allow_non_recommended_settings=self.allow_non_recommended_settings,
725
+ pooled_input_plan=self._pooled_input_plan,
600
726
  )
727
+ self._backend_pooled_input_plan = self._pooled_input_plan
601
728
  emit_progress("model.ready", model_name=self.name, device=str(self._backend.device))
602
729
  return self._backend
603
730
 
@@ -838,12 +965,12 @@ def _validate_model_config(
838
965
  info = encoder_registry.info(name)
839
966
  if info["level"] != "tile":
840
967
  raise ValueError("Hierarchical preprocessing is only supported for tile encoders")
968
+ model._prepare_pooled_input(preprocessing, emit_run_info=True)
841
969
  # Skip precision validation for CPU execution (fp32 is always valid on CPU).
842
970
  on_cpu = model._requested_device == "cpu"
843
971
  precision = None if on_cpu or execution is None else execution.precision
844
972
  validate_encoder_config(
845
973
  name,
846
- requested_tile_size_px=preprocessing.requested_tile_size_px,
847
974
  requested_spacing_um=preprocessing.requested_spacing_um,
848
975
  precision=precision,
849
976
  output_variant=model._output_variant,
@@ -1,7 +1,9 @@
1
1
  from dataclasses import dataclass
2
2
  import json
3
- from pathlib import Path
3
+ import os
4
+ from pathlib import Path, PureWindowsPath
4
5
  from typing import Any
6
+ from uuid import uuid4
5
7
 
6
8
  import numpy as np
7
9
  import torch
@@ -69,6 +71,30 @@ class HierarchicalEmbeddingArtifact:
69
71
  return load_metadata(self.metadata_path)
70
72
 
71
73
 
74
+ @dataclass(frozen=True, kw_only=True)
75
+ class DenseRegionArtifact:
76
+ """One persisted dense ROI grid: the ``(d, gh, gw)`` payload + its geometry sidecar.
77
+
78
+ Dense emits one *directory* per slide (``dense_embeddings/[<class>/]<sample_id>/``) and
79
+ one ``<x>_<y>.pt`` / ``<x>_<y>.meta.json`` pair per ROI — the counterpart of the pooled
80
+ one-file-per-slide artifacts. Named from what slide2vec knows (slide + level-0 top-left
81
+ coordinate); soma maps its ROI ``sample_id`` back onto ``(x, y)``.
82
+ """
83
+
84
+ sample_id: str
85
+ x: int
86
+ y: int
87
+ path: Path
88
+ metadata_path: Path
89
+ feature_dim: int
90
+ grid_shape: tuple[int, int]
91
+ annotation: str | None = None
92
+
93
+ @property
94
+ def metadata(self) -> dict[str, Any]:
95
+ return load_metadata(self.metadata_path)
96
+
97
+
72
98
  def _validate_output_format(output_format: str) -> str:
73
99
  normalized = output_format.lower()
74
100
  if normalized not in {"pt", "npz"}:
@@ -112,7 +138,15 @@ def _ensure_tensor(data: Any):
112
138
 
113
139
  def _write_metadata(path: Path, metadata: dict[str, Any]) -> None:
114
140
  path.parent.mkdir(parents=True, exist_ok=True)
115
- path.write_text(json.dumps(metadata, indent=2, sort_keys=True), encoding="utf-8")
141
+ tmp_path = path.with_name(f".{path.name}.tmp-{os.getpid()}-{uuid4().hex}")
142
+ try:
143
+ tmp_path.write_text(
144
+ json.dumps(metadata, indent=2, sort_keys=True),
145
+ encoding="utf-8",
146
+ )
147
+ os.replace(tmp_path, path)
148
+ finally:
149
+ tmp_path.unlink(missing_ok=True)
116
150
 
117
151
 
118
152
  def tile_embeddings_subdir(annotation: str | None) -> str:
@@ -162,6 +196,89 @@ def hierarchical_embeddings_subdir(annotation: str | None) -> str:
162
196
  return f"hierarchical_embeddings/{annotation}"
163
197
 
164
198
 
199
+ def _validate_path_component(value: str, *, field: str) -> str:
200
+ component = str(value)
201
+ if (
202
+ not component
203
+ or component in {".", ".."}
204
+ or "/" in component
205
+ or "\\" in component
206
+ or "\x00" in component
207
+ or PureWindowsPath(component).drive
208
+ ):
209
+ raise ValueError(f"{field} must be a non-empty filesystem path component")
210
+ return component
211
+
212
+
213
+ def dense_embeddings_subdir(annotation: str | None) -> str:
214
+ """Namespace the ``dense_embeddings`` output dir per annotation class.
215
+
216
+ Reuses hs2p's flatten rule (the single source of truth, shared with
217
+ :func:`tile_embeddings_subdir` and the other pooled subdir helpers): ``None`` and the
218
+ sentinel ``"tissue"`` collapse to the flat ``dense_embeddings`` root; any real class
219
+ label gets its own ``dense_embeddings/<class>`` subdirectory.
220
+ """
221
+ if is_flattened_annotation(annotation):
222
+ return "dense_embeddings"
223
+ annotation_component = _validate_path_component(annotation, field="annotation")
224
+ return f"dense_embeddings/{annotation_component}"
225
+
226
+
227
+ def region_dense_paths(
228
+ output_dir: str | Path, *, sample_id: str, annotation: str | None, x: int, y: int
229
+ ) -> tuple[Path, Path]:
230
+ """``(payload_path, sidecar_path)`` for one ROI: ``.../<sample_id>/<x>_<y>.{pt,meta.json}``.
231
+
232
+ Geometry-independent (named only from slide + level-0 ``(x, y)`` + class), so the resume
233
+ check can test sidecar existence before any slide is opened.
234
+ """
235
+ output_root = Path(output_dir).expanduser().resolve()
236
+ sample_component = _validate_path_component(sample_id, field="sample_id")
237
+ slide_dir = (output_root / dense_embeddings_subdir(annotation) / sample_component).resolve()
238
+ if not slide_dir.is_relative_to(output_root):
239
+ raise ValueError("Dense artifact path must stay within output_dir")
240
+ stem = f"{int(x)}_{int(y)}"
241
+ return slide_dir / f"{stem}.pt", slide_dir / f"{stem}.meta.json"
242
+
243
+
244
+ def write_dense_region(
245
+ grid,
246
+ *,
247
+ output_dir: str | Path,
248
+ sample_id: str,
249
+ annotation: str | None,
250
+ x: int,
251
+ y: int,
252
+ metadata: dict[str, Any],
253
+ ) -> DenseRegionArtifact:
254
+ """Persist one ``(d, gh, gw)`` grid + its geometry sidecar, atomically and sidecar-last.
255
+
256
+ Write order (D6): payload to a temp file in the destination directory → ``os.replace``
257
+ into ``<x>_<y>.pt`` (atomic on the same filesystem) → then the ``<x>_<y>.meta.json``
258
+ sidecar. A payload present without its sidecar therefore unambiguously means an
259
+ incomplete ROI, and resume treats the sidecar as the done-marker.
260
+ """
261
+ payload_path, metadata_path = region_dense_paths(
262
+ output_dir, sample_id=sample_id, annotation=annotation, x=x, y=y
263
+ )
264
+ payload_path.parent.mkdir(parents=True, exist_ok=True)
265
+ grid_array = _ensure_array(grid)
266
+ tmp_path = payload_path.with_name(f"{payload_path.name}.tmp-{os.getpid()}")
267
+ torch.save(_ensure_tensor(grid), tmp_path)
268
+ os.replace(tmp_path, payload_path)
269
+ _write_metadata(metadata_path, metadata)
270
+ return DenseRegionArtifact(
271
+ sample_id=sample_id,
272
+ x=int(x),
273
+ y=int(y),
274
+ path=payload_path,
275
+ metadata_path=metadata_path,
276
+ feature_dim=int(grid_array.shape[0]),
277
+ grid_shape=(int(grid_array.shape[1]), int(grid_array.shape[2])),
278
+ annotation=annotation,
279
+ )
280
+
281
+
165
282
  def _setup_artifact_paths(
166
283
  output_dir: str | Path, subdir: str, sample_id: str, output_format: str
167
284
  ) -> tuple[Path, Path]:
@@ -26,6 +26,7 @@ tiling:
26
26
  read_coordinates_from: # path to an existing directory containing pre-extracted `.coordinates.npz` / `.coordinates.meta.json` artifacts to reuse instead of starting tiling from scratch
27
27
  read_tiles_from: # path to an existing directory containing pre-extracted `.tiles.tar` tile stores to reuse instead of starting tiling from scratch
28
28
  backend: "auto" # backend to use for slide reading; "auto" lets hs2p resolve the best backend per slide, preferring cuCIM when available
29
+ mask_backend: "auto" # backend for reading source masks (precomputed tissue / annotation), resolved independently from the mask path; "auto" probes openability like `backend`. Set explicitly (e.g. "openslide") when a mask needs a different decoder than its slide — hs2p (>=4.3.0) no longer silently falls back
29
30
  independent_sampling: true # selection strategy when annotation sampling is active. true: sample each class independently against its own binary mask (independent selection); false: sample once over the union of active classes, then post-filter per class by coverage (joint selection). Ignored when the masks vocabulary is left at the tissue-only default.
30
31
  masks:
31
32
  # Annotation-mask vocabulary forwarded to hs2p's sampling resolver. The shipped default