slide2vec 5.1.1__tar.gz → 5.2.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 (102) hide show
  1. {slide2vec-5.1.1 → slide2vec-5.2.0}/PKG-INFO +1 -1
  2. {slide2vec-5.1.1 → slide2vec-5.2.0}/pyproject.toml +2 -2
  3. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/__init__.py +1 -1
  4. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/api.py +11 -1
  5. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/artifacts.py +22 -0
  6. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/configs/default.yaml +1 -0
  7. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/__init__.py +4 -0
  8. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/models/conch.py +2 -0
  9. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/models/gigapath.py +1 -0
  10. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/models/hibou.py +2 -0
  11. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/models/hoptimus.py +3 -0
  12. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/models/lunit.py +1 -0
  13. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/models/midnight.py +1 -0
  14. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/models/musk.py +1 -0
  15. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/models/phikon.py +2 -0
  16. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/models/prost40m.py +1 -0
  17. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/models/uni.py +2 -0
  18. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/models/virchow.py +2 -0
  19. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/registry.py +43 -0
  20. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/inference.py +16 -0
  21. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/dense_regions.py +3 -3
  22. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/embedding.py +12 -6
  23. slide2vec-5.2.0/slide2vec/runtime/model_settings.py +97 -0
  24. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/patient_pipeline.py +9 -2
  25. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/serialization.py +3 -0
  26. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec.egg-info/PKG-INFO +1 -1
  27. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec.egg-info/SOURCES.txt +1 -0
  28. slide2vec-5.2.0/tests/test_patch_size_metadata.py +191 -0
  29. {slide2vec-5.1.1 → slide2vec-5.2.0}/tests/test_regression_core.py +164 -0
  30. {slide2vec-5.1.1 → slide2vec-5.2.0}/tests/test_regression_inference.py +5 -0
  31. slide2vec-5.1.1/slide2vec/runtime/model_settings.py +0 -48
  32. {slide2vec-5.1.1 → slide2vec-5.2.0}/LICENSE +0 -0
  33. {slide2vec-5.1.1 → slide2vec-5.2.0}/README.md +0 -0
  34. {slide2vec-5.1.1 → slide2vec-5.2.0}/setup.cfg +0 -0
  35. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/__main__.py +0 -0
  36. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/cli.py +0 -0
  37. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/configs/__init__.py +0 -0
  38. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/configs/resources.py +0 -0
  39. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/data/__init__.py +0 -0
  40. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/data/dataset.py +0 -0
  41. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/data/tile_reader.py +0 -0
  42. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/data/tile_store.py +0 -0
  43. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/distributed/__init__.py +0 -0
  44. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/distributed/direct_embed_worker.py +0 -0
  45. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/distributed/pipeline_worker.py +0 -0
  46. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/base.py +0 -0
  47. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/models/__init__.py +0 -0
  48. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/models/moozy/__init__.py +0 -0
  49. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/models/moozy/blocks.py +0 -0
  50. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/models/moozy/case.py +0 -0
  51. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/models/moozy/loading.py +0 -0
  52. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/models/moozy/slide.py +0 -0
  53. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/models/moozy/types.py +0 -0
  54. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/models/prism.py +0 -0
  55. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/models/titan.py +0 -0
  56. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/encoders/validation.py +0 -0
  57. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/progress.py +0 -0
  58. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/__init__.py +0 -0
  59. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/artifacts_collect.py +0 -0
  60. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/batching.py +0 -0
  61. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/cpu_budget.py +0 -0
  62. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/dense_sliding.py +0 -0
  63. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/distributed.py +0 -0
  64. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/distributed_stage.py +0 -0
  65. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/embedding_persist.py +0 -0
  66. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/embedding_pipeline.py +0 -0
  67. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/hierarchical.py +0 -0
  68. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/manifest.py +0 -0
  69. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/persist_callbacks.py +0 -0
  70. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/persistence.py +0 -0
  71. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/process_list.py +0 -0
  72. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/progress_bridge.py +0 -0
  73. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/registry.py +0 -0
  74. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/slide_encode.py +0 -0
  75. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/tiling.py +0 -0
  76. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/tiling_pipeline.py +0 -0
  77. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/types.py +0 -0
  78. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/runtime/worker_io.py +0 -0
  79. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/utils/__init__.py +0 -0
  80. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/utils/config.py +0 -0
  81. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/utils/coordinates.py +0 -0
  82. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/utils/log_utils.py +0 -0
  83. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/utils/tiling_io.py +0 -0
  84. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec/utils/utils.py +0 -0
  85. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec.egg-info/dependency_links.txt +0 -0
  86. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec.egg-info/entry_points.txt +0 -0
  87. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec.egg-info/not-zip-safe +0 -0
  88. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec.egg-info/requires.txt +0 -0
  89. {slide2vec-5.1.1 → slide2vec-5.2.0}/slide2vec.egg-info/top_level.txt +0 -0
  90. {slide2vec-5.1.1 → slide2vec-5.2.0}/tests/test_architecture_runtime_split.py +0 -0
  91. {slide2vec-5.1.1 → slide2vec-5.2.0}/tests/test_attention_extraction.py +0 -0
  92. {slide2vec-5.1.1 → slide2vec-5.2.0}/tests/test_dense_extraction.py +0 -0
  93. {slide2vec-5.1.1 → slide2vec-5.2.0}/tests/test_dense_regions.py +0 -0
  94. {slide2vec-5.1.1 → slide2vec-5.2.0}/tests/test_dense_sliding.py +0 -0
  95. {slide2vec-5.1.1 → slide2vec-5.2.0}/tests/test_encoder_registry.py +0 -0
  96. {slide2vec-5.1.1 → slide2vec-5.2.0}/tests/test_hs2p_package_cutover.py +0 -0
  97. {slide2vec-5.1.1 → slide2vec-5.2.0}/tests/test_output_consistency.py +0 -0
  98. {slide2vec-5.1.1 → slide2vec-5.2.0}/tests/test_progress.py +0 -0
  99. {slide2vec-5.1.1 → slide2vec-5.2.0}/tests/test_regression_models.py +0 -0
  100. {slide2vec-5.1.1 → slide2vec-5.2.0}/tests/test_runtime_batching.py +0 -0
  101. {slide2vec-5.1.1 → slide2vec-5.2.0}/tests/test_tile_store.py +0 -0
  102. {slide2vec-5.1.1 → slide2vec-5.2.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.1.1
3
+ Version: 5.2.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "slide2vec"
7
- version = "5.1.1"
7
+ version = "5.2.0"
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.1.1"
170
+ current_version = "5.2.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
@@ -11,7 +11,7 @@ from slide2vec.api import (
11
11
  from slide2vec.artifacts import HierarchicalEmbeddingArtifact, SlideEmbeddingArtifact, TileEmbeddingArtifact
12
12
 
13
13
 
14
- __version__ = "5.1.1"
14
+ __version__ = "5.2.0"
15
15
 
16
16
  __all__ = [
17
17
  "Model",
@@ -21,7 +21,11 @@ from slide2vec.encoders.registry import (
21
21
  resolve_preprocessing_defaults,
22
22
  )
23
23
  from slide2vec.encoders.validation import validate_encoder_config
24
- from slide2vec.runtime.model_settings import canonicalize_model_name, normalize_precision_name
24
+ from slide2vec.runtime.model_settings import (
25
+ canonicalize_model_name,
26
+ normalize_output_dtype,
27
+ normalize_precision_name,
28
+ )
25
29
  from slide2vec.progress import emit_progress
26
30
  from slide2vec.runtime.types import LoadedModel
27
31
  from slide2vec.utils.utils import cpu_worker_limit, slurm_cpu_limit
@@ -226,6 +230,10 @@ class ExecutionOptions:
226
230
  #: Forward-pass dtype — ``"fp16"``, ``"bf16"``, ``"fp32"``,
227
231
  #: or ``None`` (auto-determined from the model preset).
228
232
  precision: str | None = None
233
+ #: On-disk feature dtype — ``"fp16"``, ``"fp32"``, or ``None`` to follow
234
+ #: :attr:`precision` (fp16 → fp16, else fp32). Applies to tile, slide, hierarchical,
235
+ #: and patient artifacts; ``"bf16"`` is rejected (numpy has no bfloat16).
236
+ output_dtype: str | None = None
229
237
  #: DataLoader prefetch queue depth per worker (default ``4``).
230
238
  prefetch_factor: int = 4
231
239
  #: Persist tile embeddings to disk when running a slide-level model.
@@ -253,6 +261,7 @@ class ExecutionOptions:
253
261
  ),
254
262
  num_gpus=1 if run_on_cpu else (int(configured_num_gpus) if configured_num_gpus is not None else None),
255
263
  precision="fp32" if run_on_cpu else requested_precision,
264
+ output_dtype=getattr(cfg.speed, "output_dtype", None),
256
265
  prefetch_factor=prefetch_factor,
257
266
  save_tile_embeddings=bool(cfg.model.save_tile_embeddings),
258
267
  save_slide_embeddings=bool(cfg.model.save_slide_embeddings),
@@ -263,6 +272,7 @@ class ExecutionOptions:
263
272
  resolved_num_gpus = _default_num_gpus() if self.num_gpus is None else self.num_gpus
264
273
  object.__setattr__(self, "num_gpus", resolved_num_gpus)
265
274
  object.__setattr__(self, "precision", normalize_precision_name(self.precision))
275
+ object.__setattr__(self, "output_dtype", normalize_output_dtype(self.output_dtype))
266
276
  if resolved_num_gpus < 1:
267
277
  raise ValueError("ExecutionOptions.num_gpus must be at least 1")
268
278
  if self.prefetch_factor < 1:
@@ -7,6 +7,8 @@ import numpy as np
7
7
  import torch
8
8
  from hs2p.fileops import is_flattened_annotation
9
9
 
10
+ from slide2vec.runtime.model_settings import output_torch_dtype
11
+
10
12
 
11
13
  @dataclass(frozen=True, kw_only=True)
12
14
  class TileEmbeddingArtifact:
@@ -74,6 +76,26 @@ def _validate_output_format(output_format: str) -> str:
74
76
  return normalized
75
77
 
76
78
 
79
+ _OUTPUT_NUMPY_DTYPE = {"fp16": np.float16, "fp32": np.float32}
80
+
81
+
82
+ def cast_feature_dtype(data: Any, precision: str) -> Any:
83
+ """Cast features to the on-disk ``precision`` (``"fp16"`` / ``"fp32"``), keeping their kind.
84
+
85
+ Torch tensors are cast via ``.to`` and arrays via ``astype``; ``None`` (no features)
86
+ passes through. This is what makes the pooled tile/slide/hierarchical/patient artifacts
87
+ land in a deterministic dtype, mirroring the dense path's ``output_dtype``. The precision
88
+ is resolved upstream by :func:`slide2vec.runtime.model_settings.resolve_output_precision`,
89
+ so only ``"fp16"`` / ``"fp32"`` reach here.
90
+ """
91
+ if data is None:
92
+ return data
93
+ torch_dtype = output_torch_dtype(precision) # validates precision (shared string→dtype map)
94
+ if torch.is_tensor(data):
95
+ return data.to(torch_dtype)
96
+ return np.asarray(data).astype(_OUTPUT_NUMPY_DTYPE[precision], copy=False)
97
+
98
+
77
99
  def _ensure_array(data: Any) -> np.ndarray:
78
100
  if isinstance(data, np.ndarray):
79
101
  return data
@@ -88,6 +88,7 @@ tiling:
88
88
 
89
89
  speed:
90
90
  precision: # model inference precision ["fp32", "fp16", "bf16"]; if not set, determined automatically based on model recommendations
91
+ output_dtype: # on-disk feature dtype ["fp16", "fp32"]; if not set, follows speed.precision (fp16 -> fp16, otherwise fp32)
91
92
  num_dataloader_workers: # number of DataLoader worker processes per GPU rank for reading tiles during embedding; defaults to auto (job CPU budget split across GPUs, except cuCIM on-the-fly uses per-GPU budget // speed.num_cucim_workers)
92
93
  num_gpus: # number of GPUs to use for feature extraction; defaults to all available GPUs
93
94
  num_preprocessing_workers: # number of workers for hs2p tiling (WSI reading, JPEG encoding, tar writing); defaults to the runtime CPU budget capped at 64
@@ -16,8 +16,10 @@ from slide2vec.encoders.base import (
16
16
  )
17
17
  from slide2vec.encoders.registry import (
18
18
  encoder_registry,
19
+ normalize_patch_size,
19
20
  register_encoder,
20
21
  resolve_encoder_output,
22
+ resolve_patch_size,
21
23
  resolve_preprocessing_requirements,
22
24
  resolve_tile_dependency_output,
23
25
  )
@@ -35,7 +37,9 @@ __all__ = [
35
37
  "resolve_recommended_dynamic_img_size",
36
38
  "resolve_requested_output_variant",
37
39
  "encoder_registry",
40
+ "normalize_patch_size",
38
41
  "register_encoder",
42
+ "resolve_patch_size",
39
43
  "resolve_preprocessing_requirements",
40
44
  "resolve_encoder_output",
41
45
  "resolve_tile_dependency_output",
@@ -78,6 +78,7 @@ def _encode_trunk_dense(*, trunk, batch: Tensor, encoder_name: str) -> Tensor:
78
78
  output_variants={"default": {"encode_dim": 512}},
79
79
  default_output_variant="default",
80
80
  input_size=448,
81
+ patch_size=16,
81
82
  supported_spacing_um=0.5,
82
83
  precision="fp32",
83
84
  source="MahmoodLab/conch",
@@ -163,6 +164,7 @@ class CONCH(TileEncoder):
163
164
  output_variants={"default": {"encode_dim": 768}},
164
165
  default_output_variant="default",
165
166
  input_size=448,
167
+ patch_size=16,
166
168
  supported_spacing_um=0.5,
167
169
  precision="fp16",
168
170
  source="MahmoodLab/TITAN",
@@ -27,6 +27,7 @@ _GIGAPATH_STD = (0.229, 0.224, 0.225)
27
27
  output_variants={"default": {"encode_dim": 1536}},
28
28
  default_output_variant="default",
29
29
  input_size=256,
30
+ patch_size=14,
30
31
  supported_spacing_um=0.5,
31
32
  precision="fp16",
32
33
  source="prov-gigapath/prov-gigapath",
@@ -151,6 +151,7 @@ class _HibouBase(TileEncoder):
151
151
  output_variants={"default": {"encode_dim": 768}},
152
152
  default_output_variant="default",
153
153
  input_size=224,
154
+ patch_size=14,
154
155
  supported_spacing_um=0.5,
155
156
  precision="fp16",
156
157
  source="histai/hibou-b",
@@ -167,6 +168,7 @@ class HibouB(_HibouBase):
167
168
  output_variants={"default": {"encode_dim": 1024}},
168
169
  default_output_variant="default",
169
170
  input_size=224,
171
+ patch_size=14,
170
172
  supported_spacing_um=0.5,
171
173
  precision="fp16",
172
174
  source="histai/hibou-L",
@@ -72,6 +72,7 @@ class _HOptimusBase(TimmTileEncoder):
72
72
  output_variants={"default": {"encode_dim": 1536}},
73
73
  default_output_variant="default",
74
74
  input_size=224,
75
+ patch_size=14,
75
76
  supported_spacing_um=0.5,
76
77
  precision="fp16",
77
78
  source="bioptimus/H-optimus-0",
@@ -108,6 +109,7 @@ class HOptimus0(TimmTileEncoder):
108
109
  output_variants={"default": {"encode_dim": 1536}},
109
110
  default_output_variant="default",
110
111
  input_size=224,
112
+ patch_size=14,
111
113
  supported_spacing_um=0.5,
112
114
  precision="fp16",
113
115
  source="bioptimus/H-optimus-1",
@@ -145,6 +147,7 @@ class HOptimus1(TimmTileEncoder):
145
147
  },
146
148
  default_output_variant="cls_patch_mean",
147
149
  input_size=224,
150
+ patch_size=14,
148
151
  supported_spacing_um=0.5,
149
152
  precision="fp16",
150
153
  source="bioptimus/H0-mini",
@@ -9,6 +9,7 @@ from slide2vec.encoders.registry import register_encoder
9
9
  output_variants={"default": {"encode_dim": 384}},
10
10
  default_output_variant="default",
11
11
  input_size=224,
12
+ patch_size=8,
12
13
  supported_spacing_um=0.5,
13
14
  precision="fp32",
14
15
  source="1aurent/vit_small_patch8_224.lunit_dino",
@@ -27,6 +27,7 @@ from slide2vec.encoders.registry import register_encoder
27
27
  output_variants={"default": {"encode_dim": 3072}},
28
28
  default_output_variant="default",
29
29
  input_size=224,
30
+ patch_size=14,
30
31
  supported_spacing_um=[0.25, 0.5, 1.0, 2.0],
31
32
  precision="fp16",
32
33
  source="kaiko-ai/midnight",
@@ -35,6 +35,7 @@ def _as_hw(value: int | tuple[int, int] | list[int]) -> tuple[int, int]:
35
35
  },
36
36
  default_output_variant="ms_aug",
37
37
  input_size=384,
38
+ patch_size=16,
38
39
  supported_spacing_um=[0.25, 0.5, 1.0],
39
40
  precision="fp16",
40
41
  source="xiangjx/musk",
@@ -148,6 +148,7 @@ class _PhikonBase(TileEncoder):
148
148
  output_variants={"default": {"encode_dim": 768}},
149
149
  default_output_variant="default",
150
150
  input_size=224,
151
+ patch_size=16,
151
152
  supported_spacing_um=0.5,
152
153
  precision="fp32",
153
154
  source="owkin/phikon",
@@ -165,6 +166,7 @@ class Phikon(_PhikonBase):
165
166
  output_variants={"default": {"encode_dim": 1024}},
166
167
  default_output_variant="default",
167
168
  input_size=224,
169
+ patch_size=16,
168
170
  supported_spacing_um=0.5,
169
171
  precision="fp32",
170
172
  source="owkin/phikon-v2",
@@ -9,6 +9,7 @@ from slide2vec.encoders.registry import register_encoder
9
9
  output_variants={"default": {"encode_dim": 384}},
10
10
  default_output_variant="default",
11
11
  input_size=224,
12
+ patch_size=16,
12
13
  supported_spacing_um=0.5,
13
14
  precision="fp32",
14
15
  source="waticlems/Prost40M",
@@ -13,6 +13,7 @@ from slide2vec.encoders.registry import register_encoder
13
13
  output_variants={"default": {"encode_dim": 1024}},
14
14
  default_output_variant="default",
15
15
  input_size=224,
16
+ patch_size=16,
16
17
  supported_spacing_um=0.5,
17
18
  precision="fp16",
18
19
  source="MahmoodLab/UNI",
@@ -32,6 +33,7 @@ class UNI(TimmTileEncoder):
32
33
  output_variants={"default": {"encode_dim": 1536}},
33
34
  default_output_variant="default",
34
35
  input_size=224,
36
+ patch_size=14,
35
37
  supported_spacing_um=0.5,
36
38
  precision="fp16",
37
39
  source="MahmoodLab/UNI2-h",
@@ -50,6 +50,7 @@ class _VirchowBase(TimmTileEncoder):
50
50
  },
51
51
  default_output_variant="cls_patch_mean",
52
52
  input_size=224,
53
+ patch_size=14,
53
54
  supported_spacing_um=0.5,
54
55
  precision="fp16",
55
56
  source="paige-ai/Virchow",
@@ -67,6 +68,7 @@ class Virchow(_VirchowBase):
67
68
  },
68
69
  default_output_variant="cls_patch_mean",
69
70
  input_size=224,
71
+ patch_size=14,
70
72
  supported_spacing_um=[0.25, 0.5, 1.0, 2.0],
71
73
  precision="fp16",
72
74
  source="paige-ai/Virchow2",
@@ -37,6 +37,7 @@ def register_encoder(
37
37
  output_variants: dict[str, dict[str, Any]],
38
38
  default_output_variant: str,
39
39
  input_size: int | None = None,
40
+ patch_size: int | tuple[int, int] | None = None,
40
41
  level: str = "tile",
41
42
  tile_encoder: str | None = None,
42
43
  tile_encoder_output_variant: str | None = None,
@@ -51,6 +52,12 @@ def register_encoder(
51
52
  output_variants: Supported named encoder outputs with concrete metadata.
52
53
  default_output_variant: Default output variant name.
53
54
  input_size: Recommended encoder input image size in pixels.
55
+ patch_size: Backbone patch size, as ``int`` (square) or ``(patch_h,
56
+ patch_w)``. Optional: only dense-capable ViT tile encoders have a
57
+ meaningful patch grid. Declared statically so the dense token grid /
58
+ cache key can be resolved via :func:`resolve_patch_size` WITHOUT
59
+ instantiating the (multi-GB) encoder; the model-load path asserts this
60
+ static value still equals the loaded model's runtime ``patch_size``.
54
61
  level: Encoder output level ("tile" or "slide").
55
62
  tile_encoder: Registered tile encoder dependency for slide-level models.
56
63
  tile_encoder_output_variant: Fixed tile-encoder output variant for slide models.
@@ -67,6 +74,7 @@ def register_encoder(
67
74
  "default_output_variant": default_output_variant,
68
75
  "level": level,
69
76
  "input_size": input_size,
77
+ "patch_size": patch_size,
70
78
  "tile_encoder": tile_encoder,
71
79
  "tile_encoder_output_variant": tile_encoder_output_variant,
72
80
  "supported_spacing_um": supported_spacing_um,
@@ -76,6 +84,41 @@ def register_encoder(
76
84
  return encoder_registry.register_decorator(name, metadata=metadata)
77
85
 
78
86
 
87
+ def normalize_patch_size(value: int | tuple[int, int]) -> tuple[int, int]:
88
+ """Normalize a patch size to a ``(patch_h, patch_w)`` int tuple.
89
+
90
+ This is the SAME representation the runtime ``encoder.patch_size`` instance
91
+ property returns, so static and runtime values compare/serialize identically
92
+ (a downstream dense cache key depends on this byte-for-byte equality).
93
+ """
94
+ if isinstance(value, int):
95
+ return (value, value)
96
+ patch_h, patch_w = value
97
+ return (int(patch_h), int(patch_w))
98
+
99
+
100
+ def resolve_patch_size(
101
+ encoder_name: str,
102
+ metadata: dict[str, Any] | None = None,
103
+ ) -> tuple[int, int]:
104
+ """Resolve an encoder's static patch size WITHOUT constructing the model.
105
+
106
+ Reads the ``patch_size`` declared on the encoder's ``@register_encoder`` and
107
+ normalizes it to a ``(patch_h, patch_w)`` int tuple (matching the runtime
108
+ ``encoder.patch_size``). Raises a clear error for encoders that do not declare
109
+ one (non-dense encoders) rather than returning a wrong value.
110
+ """
111
+ info = metadata if metadata is not None else encoder_registry.info(encoder_name)
112
+ patch = info.get("patch_size")
113
+ if patch is None:
114
+ raise ValueError(
115
+ f"Encoder '{encoder_name}' does not declare a patch_size in its registry "
116
+ "metadata. patch_size is only defined for dense-capable ViT tile encoders; "
117
+ "non-dense encoders have no recoverable patch grid."
118
+ )
119
+ return normalize_patch_size(patch)
120
+
121
+
79
122
  def resolve_preprocessing_requirements(
80
123
  encoder_name: str,
81
124
  metadata: dict[str, Any] | None = None,
@@ -59,6 +59,7 @@ from slide2vec.artifacts import (
59
59
  from slide2vec.encoders.registry import (
60
60
  encoder_registry,
61
61
  resolve_encoder_output,
62
+ resolve_patch_size,
62
63
  resolve_preprocessing_defaults,
63
64
  )
64
65
  from slide2vec.runtime.model_settings import canonicalize_model_name
@@ -121,6 +122,21 @@ def load_model(
121
122
  extra_kwargs["allow_non_recommended_settings"] = allow_non_recommended_settings
122
123
  encoder = encoder_cls(output_variant=output_variant, **extra_kwargs)
123
124
 
125
+ # Drift guard: the static patch_size declared on @register_encoder is read
126
+ # (without loading the model) to resolve the dense cache key. Assert it still
127
+ # equals the loaded model's runtime patch_size so the static metadata can never
128
+ # silently diverge from the architecture and corrupt that key.
129
+ if info.get("patch_size") is not None:
130
+ static_patch_size = resolve_patch_size(name, metadata=info)
131
+ runtime_patch_size = encoder.patch_size
132
+ if tuple(runtime_patch_size) != static_patch_size:
133
+ raise ValueError(
134
+ f"Encoder '{name}' declares a static patch_size {static_patch_size} "
135
+ f"(registry metadata) but the loaded model reports "
136
+ f"{tuple(runtime_patch_size)}. The static value seeds the dense cache "
137
+ "key; fix the @register_encoder patch_size to match the architecture."
138
+ )
139
+
124
140
  tile_encoder = None
125
141
  if resolved_level == "tile":
126
142
  transforms = encoder.get_transform()
@@ -39,8 +39,8 @@ import torch
39
39
  import torch.nn.functional as F
40
40
  from PIL import Image
41
41
 
42
- from slide2vec.runtime.batching import autocast_dtype
43
42
  from slide2vec.runtime.dense_sliding import encode_dense_sliding
43
+ from slide2vec.runtime.model_settings import output_torch_dtype, resolve_output_precision
44
44
  from slide2vec.runtime.slide_encode import slide_encode_autocast_ctx
45
45
 
46
46
 
@@ -54,8 +54,8 @@ def _resolve_output_dtype(output_dtype: "torch.dtype | None", precision: str) ->
54
54
  materialized via ``.numpy()`` and bfloat16 cannot cross that boundary.
55
55
  """
56
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
57
+ # Shared rule with the pooled write path: fp16 compute -> fp16, else fp32.
58
+ return output_torch_dtype(resolve_output_precision(None, precision))
59
59
  if output_dtype == torch.bfloat16:
60
60
  raise ValueError(
61
61
  "output_dtype=torch.bfloat16 cannot be materialized as a numpy grid; "
@@ -10,11 +10,13 @@ from slide2vec.artifacts import (
10
10
  HierarchicalEmbeddingArtifact,
11
11
  SlideEmbeddingArtifact,
12
12
  TileEmbeddingArtifact,
13
+ cast_feature_dtype,
13
14
  write_hierarchical_embeddings,
14
15
  write_slide_embeddings,
15
16
  write_tile_embeddings,
16
17
  )
17
18
  from slide2vec.runtime.hierarchical import resolve_hierarchical_geometry
19
+ from slide2vec.runtime.model_settings import resolve_output_precision
18
20
 
19
21
 
20
22
  def tiling_result_annotation(tiling_result) -> str | None:
@@ -114,12 +116,14 @@ def write_tile_embedding_artifact(
114
116
  ) -> TileEmbeddingArtifact:
115
117
  if execution.output_dir is None:
116
118
  raise ValueError("ExecutionOptions.output_dir is required to persist tile embeddings")
119
+ precision = resolve_output_precision(execution.output_dtype, execution.precision)
120
+ features = cast_feature_dtype(features, precision)
117
121
  return write_tile_embeddings(
118
122
  sample_id,
119
123
  features,
120
124
  output_dir=execution.output_dir,
121
125
  output_format=execution.output_format,
122
- metadata=metadata,
126
+ metadata={**metadata, "feature_dtype": precision},
123
127
  tile_index=np.arange(_num_rows(features), dtype=np.int64),
124
128
  annotation=annotation,
125
129
  )
@@ -142,13 +146,14 @@ def write_slide_embedding_artifact(
142
146
  ) -> SlideEmbeddingArtifact:
143
147
  if execution.output_dir is None:
144
148
  raise ValueError("ExecutionOptions.output_dir is required to persist slide embeddings")
149
+ precision = resolve_output_precision(execution.output_dtype, execution.precision)
145
150
  return write_slide_embeddings(
146
151
  sample_id,
147
- embedding,
152
+ cast_feature_dtype(embedding, precision),
148
153
  output_dir=execution.output_dir,
149
154
  output_format=execution.output_format,
150
- metadata=metadata,
151
- latents=latents,
155
+ metadata={**metadata, "feature_dtype": precision},
156
+ latents=cast_feature_dtype(latents, precision),
152
157
  annotation=annotation,
153
158
  )
154
159
 
@@ -163,11 +168,12 @@ def write_hierarchical_embedding_artifact(
163
168
  ) -> HierarchicalEmbeddingArtifact:
164
169
  if execution.output_dir is None:
165
170
  raise ValueError("ExecutionOptions.output_dir is required to persist hierarchical embeddings")
171
+ precision = resolve_output_precision(execution.output_dtype, execution.precision)
166
172
  return write_hierarchical_embeddings(
167
173
  sample_id,
168
- features,
174
+ cast_feature_dtype(features, precision),
169
175
  output_dir=execution.output_dir,
170
176
  output_format=execution.output_format,
171
- metadata=metadata,
177
+ metadata={**metadata, "feature_dtype": precision},
172
178
  annotation=annotation,
173
179
  )
@@ -0,0 +1,97 @@
1
+ import logging
2
+ from typing import Any
3
+
4
+ logger = logging.getLogger("slide2vec")
5
+
6
+
7
+ PRECISION_ALIASES = {
8
+ "fp32": "fp32",
9
+ "float32": "fp32",
10
+ "32": "fp32",
11
+ "fp16": "fp16",
12
+ "float16": "fp16",
13
+ "16": "fp16",
14
+ "half": "fp16",
15
+ "bf16": "bf16",
16
+ "bfloat16": "bf16",
17
+ }
18
+
19
+ MODEL_NAME_ALIASES = {
20
+ "conch-v1.5": "conchv15",
21
+ "conch_v15": "conchv15",
22
+ "conchv1.5": "conchv15",
23
+ "conchv1_5": "conchv15",
24
+ "phikon-v2": "phikonv2",
25
+ "hibou-b": "hibou-b",
26
+ "hibou-l": "hibou-l",
27
+ "h-optimus-0-mini": "h0-mini",
28
+ "prov-gigapath": "gigapath",
29
+ "prov-gigapath-tile": "gigapath",
30
+ "prov-gigapath-slide": "gigapath-slide",
31
+ "kaiko-midnight": "midnight",
32
+ }
33
+
34
+
35
+ def normalize_precision_name(value: Any) -> str | None:
36
+ if value is None:
37
+ return None
38
+ normalized = str(value).strip().lower()
39
+ if normalized not in PRECISION_ALIASES:
40
+ supported = ", ".join(sorted(PRECISION_ALIASES))
41
+ raise ValueError(f"Unsupported precision {value!r}. Expected one of: {supported}")
42
+ return PRECISION_ALIASES[normalized]
43
+
44
+
45
+ def normalize_output_dtype(value: Any) -> str | None:
46
+ """Normalize a requested on-disk feature dtype to ``"fp16"`` / ``"fp32"`` (or ``None``).
47
+
48
+ ``None`` means *follow the compute precision* (see :func:`resolve_output_precision`).
49
+ ``bf16`` is rejected: tile/slide/hierarchical/patient artifacts serialize through numpy,
50
+ which has no bfloat16 — the same boundary the dense path guards.
51
+ """
52
+ normalized = normalize_precision_name(value)
53
+ if normalized == "bf16":
54
+ raise ValueError(
55
+ "Unsupported output dtype 'bf16'. Feature artifacts serialize through numpy "
56
+ "(no bfloat16); choose 'fp16' or 'fp32', or leave unset to follow precision."
57
+ )
58
+ return normalized
59
+
60
+
61
+ def resolve_output_precision(output_dtype: Any, compute_precision: Any) -> str:
62
+ """Resolve the concrete on-disk feature precision (``"fp16"`` or ``"fp32"``).
63
+
64
+ ``output_dtype is None`` follows ``compute_precision``: an fp16 forward keeps fp16
65
+ features, while bf16 / fp32 (and an unset or unknown compute precision) widen to fp32 —
66
+ fp32 is bf16's lossless container and the only float dtype a numpy artifact can hold.
67
+ A non-null ``output_dtype`` is honored verbatim (after :func:`normalize_output_dtype`).
68
+ This is the single source of truth shared by the pooled write path and the dense
69
+ ``iter_regions_dense`` path.
70
+ """
71
+ normalized = normalize_output_dtype(output_dtype)
72
+ if normalized is not None:
73
+ return normalized
74
+ return "fp16" if normalize_precision_name(compute_precision) == "fp16" else "fp32"
75
+
76
+
77
+ def output_torch_dtype(precision: str):
78
+ """The torch dtype an on-disk feature artifact materializes in for ``precision``.
79
+
80
+ ``precision`` is a value returned by :func:`resolve_output_precision` (``"fp16"`` or
81
+ ``"fp32"``); anything else is a programming error. This is the single string→dtype
82
+ mapping shared by the pooled write path (:func:`slide2vec.artifacts.cast_feature_dtype`)
83
+ and the dense ``iter_regions_dense`` path, so both agree on the materialized dtype.
84
+ torch is imported lazily to keep this module importable without it.
85
+ """
86
+ import torch
87
+
88
+ mapping = {"fp16": torch.float16, "fp32": torch.float32}
89
+ if precision not in mapping:
90
+ raise ValueError(f"Unsupported output precision {precision!r}; expected 'fp16' or 'fp32'.")
91
+ return mapping[precision]
92
+
93
+
94
+ def canonicalize_model_name(name: str) -> str:
95
+ normalized = name.strip().lower()
96
+ return MODEL_NAME_ALIASES.get(normalized, normalized)
97
+
@@ -11,6 +11,7 @@ from slide2vec.artifacts import (
11
11
  PatientEmbeddingArtifact,
12
12
  SlideEmbeddingArtifact,
13
13
  TileEmbeddingArtifact,
14
+ cast_feature_dtype,
14
15
  write_patient_embeddings,
15
16
  )
16
17
  from slide2vec.progress import emit_progress
@@ -22,6 +23,7 @@ from slide2vec.runtime.embedding import (
22
23
  )
23
24
  from slide2vec.runtime.embedding_pipeline import compute_tile_embeddings_for_slide
24
25
  from slide2vec.runtime.hierarchical import num_embedding_items
26
+ from slide2vec.runtime.model_settings import resolve_output_precision
25
27
  from slide2vec.runtime.slide_encode import encode_slide_from_tiles
26
28
  from slide2vec.runtime.tiling import resolve_slide_backend
27
29
 
@@ -117,12 +119,17 @@ def run_patient_pipeline(
117
119
  stacked = torch.stack(slide_embs, dim=0).to(loaded.device)
118
120
  with torch.inference_mode():
119
121
  patient_emb = loaded.model.encode_patient(stacked).detach().cpu()
122
+ precision = resolve_output_precision(execution.output_dtype, execution.precision)
120
123
  artifact = write_patient_embeddings(
121
124
  patient_id,
122
- patient_emb,
125
+ cast_feature_dtype(patient_emb, precision),
123
126
  output_dir=output_dir,
124
127
  output_format=execution.output_format,
125
- metadata={"encoder_name": model.name, "encoder_level": model.level},
128
+ metadata={
129
+ "encoder_name": model.name,
130
+ "encoder_level": model.level,
131
+ "feature_dtype": precision,
132
+ },
126
133
  num_slides=patient_slide_counts[patient_id],
127
134
  )
128
135
  patient_artifacts.append(artifact)
@@ -52,6 +52,7 @@ def serialize_execution(
52
52
  "num_preprocessing_workers": execution.num_preprocessing_workers,
53
53
  "num_gpus": execution.num_gpus,
54
54
  "precision": execution.precision,
55
+ "output_dtype": execution.output_dtype,
55
56
  "prefetch_factor": execution.prefetch_factor,
56
57
  "save_tile_embeddings": execution.save_tile_embeddings,
57
58
  "save_slide_embeddings": execution.save_slide_embeddings,
@@ -104,6 +105,7 @@ def deserialize_execution(payload: dict[str, Any]) -> ExecutionOptions:
104
105
  num_preprocessing_workers = payload.get("num_preprocessing_workers")
105
106
  num_gpus = payload.get("num_gpus", 1)
106
107
  precision = payload.get("precision", "fp32")
108
+ output_dtype = payload.get("output_dtype")
107
109
  prefetch_factor = payload.get("prefetch_factor", 4)
108
110
  save_tile_embeddings = bool(payload.get("save_tile_embeddings", False))
109
111
  save_slide_embeddings = bool(payload.get("save_slide_embeddings", False))
@@ -118,6 +120,7 @@ def deserialize_execution(payload: dict[str, Any]) -> ExecutionOptions:
118
120
  ),
119
121
  num_gpus=int(num_gpus),
120
122
  precision=precision,
123
+ output_dtype=output_dtype,
121
124
  prefetch_factor=int(prefetch_factor),
122
125
  save_tile_embeddings=save_tile_embeddings,
123
126
  save_slide_embeddings=save_slide_embeddings,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slide2vec
3
- Version: 5.1.1
3
+ Version: 5.2.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
@@ -89,6 +89,7 @@ tests/test_dense_sliding.py
89
89
  tests/test_encoder_registry.py
90
90
  tests/test_hs2p_package_cutover.py
91
91
  tests/test_output_consistency.py
92
+ tests/test_patch_size_metadata.py
92
93
  tests/test_progress.py
93
94
  tests/test_regression_core.py
94
95
  tests/test_regression_inference.py