slide2vec 5.2.0__tar.gz → 5.3.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 (107) hide show
  1. {slide2vec-5.2.0 → slide2vec-5.3.0}/PKG-INFO +2 -2
  2. {slide2vec-5.2.0 → slide2vec-5.3.0}/README.md +1 -1
  3. {slide2vec-5.2.0 → slide2vec-5.3.0}/pyproject.toml +2 -2
  4. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/__init__.py +1 -1
  5. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/models/__init__.py +8 -0
  6. slide2vec-5.3.0/slide2vec/encoders/models/dinov2.py +56 -0
  7. slide2vec-5.3.0/slide2vec/encoders/models/genbio.py +155 -0
  8. slide2vec-5.3.0/slide2vec/encoders/models/gpfm.py +89 -0
  9. slide2vec-5.3.0/slide2vec/encoders/models/mstar.py +34 -0
  10. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/registry.py +74 -26
  11. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/model_settings.py +4 -0
  12. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec.egg-info/PKG-INFO +2 -2
  13. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec.egg-info/SOURCES.txt +6 -0
  14. slide2vec-5.3.0/tests/test_dinov2_natimage.py +116 -0
  15. {slide2vec-5.2.0 → slide2vec-5.3.0}/tests/test_encoder_registry.py +51 -0
  16. slide2vec-5.3.0/tests/test_gpfm_genbio_heavy.py +64 -0
  17. {slide2vec-5.2.0 → slide2vec-5.3.0}/tests/test_patch_size_metadata.py +24 -2
  18. {slide2vec-5.2.0 → slide2vec-5.3.0}/tests/test_regression_core.py +4 -0
  19. {slide2vec-5.2.0 → slide2vec-5.3.0}/LICENSE +0 -0
  20. {slide2vec-5.2.0 → slide2vec-5.3.0}/setup.cfg +0 -0
  21. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/__main__.py +0 -0
  22. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/api.py +0 -0
  23. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/artifacts.py +0 -0
  24. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/cli.py +0 -0
  25. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/configs/__init__.py +0 -0
  26. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/configs/default.yaml +0 -0
  27. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/configs/resources.py +0 -0
  28. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/data/__init__.py +0 -0
  29. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/data/dataset.py +0 -0
  30. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/data/tile_reader.py +0 -0
  31. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/data/tile_store.py +0 -0
  32. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/distributed/__init__.py +0 -0
  33. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/distributed/direct_embed_worker.py +0 -0
  34. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/distributed/pipeline_worker.py +0 -0
  35. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/__init__.py +0 -0
  36. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/base.py +0 -0
  37. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/models/conch.py +0 -0
  38. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/models/gigapath.py +0 -0
  39. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/models/hibou.py +0 -0
  40. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/models/hoptimus.py +0 -0
  41. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/models/lunit.py +0 -0
  42. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/models/midnight.py +0 -0
  43. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/models/moozy/__init__.py +0 -0
  44. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/models/moozy/blocks.py +0 -0
  45. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/models/moozy/case.py +0 -0
  46. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/models/moozy/loading.py +0 -0
  47. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/models/moozy/slide.py +0 -0
  48. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/models/moozy/types.py +0 -0
  49. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/models/musk.py +0 -0
  50. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/models/phikon.py +0 -0
  51. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/models/prism.py +0 -0
  52. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/models/prost40m.py +0 -0
  53. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/models/titan.py +0 -0
  54. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/models/uni.py +0 -0
  55. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/models/virchow.py +0 -0
  56. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/encoders/validation.py +0 -0
  57. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/inference.py +0 -0
  58. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/progress.py +0 -0
  59. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/__init__.py +0 -0
  60. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/artifacts_collect.py +0 -0
  61. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/batching.py +0 -0
  62. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/cpu_budget.py +0 -0
  63. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/dense_regions.py +0 -0
  64. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/dense_sliding.py +0 -0
  65. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/distributed.py +0 -0
  66. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/distributed_stage.py +0 -0
  67. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/embedding.py +0 -0
  68. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/embedding_persist.py +0 -0
  69. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/embedding_pipeline.py +0 -0
  70. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/hierarchical.py +0 -0
  71. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/manifest.py +0 -0
  72. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/patient_pipeline.py +0 -0
  73. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/persist_callbacks.py +0 -0
  74. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/persistence.py +0 -0
  75. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/process_list.py +0 -0
  76. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/progress_bridge.py +0 -0
  77. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/registry.py +0 -0
  78. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/serialization.py +0 -0
  79. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/slide_encode.py +0 -0
  80. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/tiling.py +0 -0
  81. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/tiling_pipeline.py +0 -0
  82. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/types.py +0 -0
  83. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/runtime/worker_io.py +0 -0
  84. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/utils/__init__.py +0 -0
  85. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/utils/config.py +0 -0
  86. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/utils/coordinates.py +0 -0
  87. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/utils/log_utils.py +0 -0
  88. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/utils/tiling_io.py +0 -0
  89. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec/utils/utils.py +0 -0
  90. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec.egg-info/dependency_links.txt +0 -0
  91. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec.egg-info/entry_points.txt +0 -0
  92. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec.egg-info/not-zip-safe +0 -0
  93. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec.egg-info/requires.txt +0 -0
  94. {slide2vec-5.2.0 → slide2vec-5.3.0}/slide2vec.egg-info/top_level.txt +0 -0
  95. {slide2vec-5.2.0 → slide2vec-5.3.0}/tests/test_architecture_runtime_split.py +0 -0
  96. {slide2vec-5.2.0 → slide2vec-5.3.0}/tests/test_attention_extraction.py +0 -0
  97. {slide2vec-5.2.0 → slide2vec-5.3.0}/tests/test_dense_extraction.py +0 -0
  98. {slide2vec-5.2.0 → slide2vec-5.3.0}/tests/test_dense_regions.py +0 -0
  99. {slide2vec-5.2.0 → slide2vec-5.3.0}/tests/test_dense_sliding.py +0 -0
  100. {slide2vec-5.2.0 → slide2vec-5.3.0}/tests/test_hs2p_package_cutover.py +0 -0
  101. {slide2vec-5.2.0 → slide2vec-5.3.0}/tests/test_output_consistency.py +0 -0
  102. {slide2vec-5.2.0 → slide2vec-5.3.0}/tests/test_progress.py +0 -0
  103. {slide2vec-5.2.0 → slide2vec-5.3.0}/tests/test_regression_inference.py +0 -0
  104. {slide2vec-5.2.0 → slide2vec-5.3.0}/tests/test_regression_models.py +0 -0
  105. {slide2vec-5.2.0 → slide2vec-5.3.0}/tests/test_runtime_batching.py +0 -0
  106. {slide2vec-5.2.0 → slide2vec-5.3.0}/tests/test_tile_store.py +0 -0
  107. {slide2vec-5.2.0 → slide2vec-5.3.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.2.0
3
+ Version: 5.3.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
@@ -225,7 +225,7 @@ The package writes explicit artifact directories:
225
225
 
226
226
  ### Supported Models
227
227
 
228
- `slide2vec` currently ships preset configs for 17 tile-level models and 3 slide-level models.
228
+ `slide2vec` currently ships preset configs for 22 tile-level models and 3 slide-level models.
229
229
  For the full catalog and preset names, see [`docs/models.md`](docs/models.md).
230
230
 
231
231
  ## CLI
@@ -119,7 +119,7 @@ The package writes explicit artifact directories:
119
119
 
120
120
  ### Supported Models
121
121
 
122
- `slide2vec` currently ships preset configs for 17 tile-level models and 3 slide-level models.
122
+ `slide2vec` currently ships preset configs for 22 tile-level models and 3 slide-level models.
123
123
  For the full catalog and preset names, see [`docs/models.md`](docs/models.md).
124
124
 
125
125
  ## CLI
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "slide2vec"
7
- version = "5.2.0"
7
+ version = "5.3.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.2.0"
170
+ current_version = "5.3.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.2.0"
14
+ __version__ = "5.3.0"
15
15
 
16
16
  __all__ = [
17
17
  "Model",
@@ -5,12 +5,16 @@ Importing this package registers all encoders in the encoder_registry.
5
5
 
6
6
  from . import (
7
7
  conch,
8
+ dinov2,
9
+ genbio,
8
10
  gigapath,
11
+ gpfm,
9
12
  hibou,
10
13
  hoptimus,
11
14
  lunit,
12
15
  midnight,
13
16
  moozy,
17
+ mstar,
14
18
  musk,
15
19
  phikon,
16
20
  prost40m,
@@ -22,12 +26,16 @@ from . import (
22
26
 
23
27
  __all__ = [
24
28
  "conch",
29
+ "dinov2",
30
+ "genbio",
25
31
  "gigapath",
32
+ "gpfm",
26
33
  "hibou",
27
34
  "hoptimus",
28
35
  "lunit",
29
36
  "midnight",
30
37
  "moozy",
38
+ "mstar",
31
39
  "musk",
32
40
  "phikon",
33
41
  "prost40m",
@@ -0,0 +1,56 @@
1
+ """Natural-image DINOv2 ViT-B/14 control encoder.
2
+
3
+ ``dinov2-vitb14`` is a **non-pathology** ViT: the original DINOv2 ViT-B/14
4
+ (Oquab et al., 2024) self-supervised on LVD-142M *natural* images, shipped by
5
+ ``timm`` as ``vit_base_patch14_dinov2.lvd142m`` (weights hosted on Hugging Face
6
+ under ``timm/vit_base_patch14_dinov2.lvd142m`` — public, no gated access).
7
+
8
+ It exists as a **control**: nearly every pathology tile encoder here (UNI,
9
+ Virchow, GigaPath, H-optimus, Midnight, …) is a DINOv2-family ViT, so pairing
10
+ them with a DINOv2 ViT trained on natural images holds the architecture and the
11
+ self-supervised objective fixed and varies only the *pretraining domain*. That
12
+ isolates the question "does pathology-pretraining actually pay off?" for a
13
+ downstream task (e.g. cell detection).
14
+
15
+ Structurally it is a plain :class:`TimmTileEncoder` (mirroring ``lunit`` /
16
+ ``prost40m`` / ``uni``): the dense (``encode_tiles_dense``) and attention
17
+ (``encode_tiles_attention``) paths are inherited unchanged from the timm ViT
18
+ base, so the control is dense-extraction- and attention-capable exactly like the
19
+ pathology encoders. ``dynamic_img_size=True`` lets the (natively 518px) backbone
20
+ run at the 224px detection tile geometry via positional-embedding interpolation,
21
+ a no-op at the native size (verified in the shared dense-extraction suite).
22
+
23
+ Spacing note: a natural-image model has **no** intrinsic micron-per-pixel
24
+ spacing, so it declares ``supported_spacing_um=None`` — it is *spacing-agnostic*
25
+ and :func:`validate_encoder_config` never rejects a requested spacing for it
26
+ (unlike the pathology encoders, which are validated at a specific spacing). It
27
+ still needs *a* spacing to tile a slide, so ``default_spacing_um=0.5`` sets the
28
+ tiling default: 0.5 µm/px is the task-spacing the pathology tile encoders
29
+ declare, so selecting this encoder by name lands on identical tile geometry and
30
+ it drops in as a matched control. Because it is agnostic, sweeping other
31
+ task-spacings (e.g. 0.25) needs no ``allow_non_recommended_settings`` escape
32
+ hatch — any requested spacing is accepted as-is.
33
+ """
34
+
35
+ from slide2vec.encoders.base import TimmTileEncoder
36
+ from slide2vec.encoders.registry import register_encoder
37
+
38
+
39
+ @register_encoder(
40
+ "dinov2-vitb14",
41
+ output_variants={"default": {"encode_dim": 768}},
42
+ default_output_variant="default",
43
+ input_size=224,
44
+ patch_size=14,
45
+ supported_spacing_um=None, # spacing-agnostic: no intrinsic µm/px, so no validation constraint
46
+ default_spacing_um=0.5, # tiling default: match the pathology encoders' task-spacing
47
+ precision="fp16",
48
+ source="timm/vit_base_patch14_dinov2.lvd142m",
49
+ )
50
+ class DINOv2ViTB14(TimmTileEncoder):
51
+ def __init__(self, *, output_variant: str | None = None):
52
+ super().__init__(
53
+ "vit_base_patch14_dinov2.lvd142m",
54
+ output_variant=output_variant,
55
+ dynamic_img_size=True, # enable dense extraction; no-op at native size
56
+ )
@@ -0,0 +1,155 @@
1
+ """GenBio-PathFM tile encoder.
2
+
3
+ GenBio-PathFM (GenBio AI, 2024; ``genbio-ai/genbio-pathfm``) is a 1.1B-param ViT
4
+ histopathology tile encoder (JEDI = JEPA + DINO training on public data). It is
5
+ loaded via HF ``AutoModel(trust_remote_code=True)`` (auto_map ->
6
+ ``GenBioPathFMModel``), so it is a custom :class:`TileEncoder`, not a
7
+ ``TimmTileEncoder``. Weights are openly downloadable (safetensors); released
8
+ under the custom **GenBio AI Community License** (not OSI-approved — read it for
9
+ acceptable-use / commercial restrictions; slide2vec only wraps user-downloaded
10
+ weights and does not redistribute them).
11
+
12
+ Output dim (4608, verified on real weights): the backbone is a *single-channel*
13
+ ViT (``in_chans=1``, ``embed_dim=1536``). The model's canonical ``forward`` takes
14
+ an RGB ``[B, 3, H, W]`` tensor, treats each colour channel as a separate
15
+ single-channel image, encodes all three, and concatenates the three per-channel
16
+ CLS tokens -> ``[B, embed_dim * 3] = [B, 4608]``. This per-channel-CLS
17
+ concatenation is the model's intrinsic design (matching the HF card's advertised
18
+ feature dimension of 4608), not an ad-hoc CLS+patch pooling; ``encode_tiles``
19
+ therefore returns the model's default ``forward`` output directly. (Confirmed by
20
+ running a ``(1, 3, 224, 224)`` dummy through the real weights -> shape
21
+ ``(1, 4608)``; see ``tests/test_gpfm_genbio_heavy.py``.)
22
+
23
+ Normalization is **non-ImageNet** (``config.json`` ``image_mean`` / ``image_std``)
24
+ and must be set explicitly.
25
+
26
+ Dense (spatial-grid) extraction is supported via the model's
27
+ ``forward_with_patches`` (a DINOv2-style ``x_norm_patchtokens`` path): it returns
28
+ the fused per-channel patch tokens ``(B, T, 4608)`` — the three single-channel
29
+ patch-token grids concatenated along the feature dim, with the prefix tokens
30
+ (CLS + storage tokens) already stripped — which fold straight into a
31
+ ``(B, 4608, h, w)`` grid. The patch size is 16 (a 224 tile -> a 14x14 = 196 grid).
32
+
33
+ Attention-map extraction is **not** supported. The backbone computes attention
34
+ with a fused ``F.scaled_dot_product_attention`` (no materialized weights, no
35
+ ``output_attentions``), and — more fundamentally — it encodes the three colour
36
+ channels as three independent single-channel images, so there is no single
37
+ coherent CLS-over-patches attention to extract: any "attention grid" would be
38
+ three separate grayscale-channel attentions. Recovering it would need a bespoke
39
+ per-channel recompute path that diverges from the shared timm/HF attention
40
+ helpers, so GenBio deliberately opts out of ``encode_tiles_attention``.
41
+ """
42
+
43
+ from typing import Callable
44
+
45
+ import torch
46
+ from torch import Tensor
47
+ from torchvision.transforms import v2
48
+
49
+ from slide2vec.encoders.base import (
50
+ TileEncoder,
51
+ preferred_default_device,
52
+ reshape_tokens_to_grid,
53
+ resolve_requested_output_variant,
54
+ )
55
+ from slide2vec.encoders.registry import register_encoder
56
+
57
+ _HF_REPO_ID = "genbio-ai/genbio-pathfm"
58
+ # Non-ImageNet normalization from the model's config.json (image_mean / image_std).
59
+ _GENBIO_MEAN = (0.697, 0.575, 0.728)
60
+ _GENBIO_STD = (0.188, 0.240, 0.187)
61
+
62
+
63
+ @register_encoder(
64
+ "genbio-pathfm",
65
+ # encode_dim 4608 = embed_dim (1536) x 3 colour channels; patch_size 16 (a 224
66
+ # tile -> a 14x14 = 196 patch-token grid) — see module docstring.
67
+ output_variants={"default": {"encode_dim": 4608}},
68
+ default_output_variant="default",
69
+ input_size=224,
70
+ patch_size=16,
71
+ supported_spacing_um=0.5, # 20x; card states no magnification, house default
72
+ precision="fp32", # upstream runs plain fp32, no autocast
73
+ source="genbio-ai/genbio-pathfm",
74
+ )
75
+ class GenBioPathFM(TileEncoder):
76
+ def __init__(self, *, output_variant: str | None = None):
77
+ from transformers import AutoModel
78
+
79
+ self._model = AutoModel.from_pretrained(_HF_REPO_ID, trust_remote_code=True).eval()
80
+ self._device = preferred_default_device()
81
+ self._output_variant = resolve_requested_output_variant(output_variant)
82
+
83
+ def get_transform(self) -> Callable:
84
+ # Mirrors the model card: Resize to an exact 224x224 (tuple form, as the
85
+ # card's ``Resize((224, 224))``) + custom Normalize. No CenterCrop: the card
86
+ # has none, and after a square resize it would be a no-op anyway.
87
+ return v2.Compose([
88
+ v2.ToImage(),
89
+ v2.Resize((224, 224), interpolation=v2.InterpolationMode.BICUBIC, antialias=True),
90
+ v2.ToDtype(torch.float32, scale=True),
91
+ v2.Normalize(mean=_GENBIO_MEAN, std=_GENBIO_STD),
92
+ ])
93
+
94
+ def get_dense_transform(self) -> Callable:
95
+ # Normalization only — no Resize/CenterCrop (see TileEncoder.get_dense_transform),
96
+ # so the dense grid stays registered to the full source tile.
97
+ return v2.Compose([
98
+ v2.ToImage(),
99
+ v2.ToDtype(torch.float32, scale=True),
100
+ v2.Normalize(mean=_GENBIO_MEAN, std=_GENBIO_STD),
101
+ ])
102
+
103
+ def encode_tiles(self, batch: Tensor) -> Tensor:
104
+ # Canonical forward: per-channel CLS tokens concatenated -> (B, 4608).
105
+ return self._model(batch)
106
+
107
+ @property
108
+ def patch_size(self) -> tuple[int, int]:
109
+ return (16, 16)
110
+
111
+ def encode_tiles_dense(self, batch: Tensor) -> Tensor:
112
+ """Encode tiles into a dense spatial grid. (B, C, H, W) -> (B, d, h, w).
113
+
114
+ Uses the model's ``forward_with_patches`` (DINOv2-style), which returns the
115
+ fused per-channel patch tokens ``(B, T, 4608)`` — the three single-channel
116
+ patch-token grids concatenated along the feature dim, with the prefix
117
+ tokens (CLS + storage tokens) already stripped — then folds that token
118
+ sequence back into its spatial grid (``num_prefix_tokens=0``). ``H, W`` must
119
+ be divisible by the patch size; non-224 inputs rely on the backbone's
120
+ DINOv2 positional-embedding interpolation.
121
+ """
122
+ if batch.ndim != 4:
123
+ raise ValueError(
124
+ "encode_tiles_dense expects a (B, C, H, W) batch, got shape "
125
+ f"{tuple(batch.shape)}."
126
+ )
127
+ _, _, height, width = batch.shape
128
+ patch_h, patch_w = self.patch_size
129
+ if height % patch_h != 0 or width % patch_w != 0:
130
+ raise ValueError(
131
+ f"Dense extraction for '{type(self).__name__}' requires input "
132
+ f"divisible by the patch size: got {height}x{width}, patch "
133
+ f"{patch_h}x{patch_w}. Pad the tile up to a patch multiple first."
134
+ )
135
+ _, patch_tokens = self._model.forward_with_patches(batch)
136
+ return reshape_tokens_to_grid(
137
+ patch_tokens,
138
+ grid_h=height // patch_h,
139
+ grid_w=width // patch_w,
140
+ num_prefix_tokens=0,
141
+ encoder_name=type(self).__name__,
142
+ )
143
+
144
+ @property
145
+ def encode_dim(self) -> int:
146
+ return 4608
147
+
148
+ @property
149
+ def device(self) -> torch.device:
150
+ return self._device
151
+
152
+ def to(self, device: torch.device | str) -> "GenBioPathFM":
153
+ self._device = torch.device(device)
154
+ self._model = self._model.to(self._device)
155
+ return self
@@ -0,0 +1,89 @@
1
+ """GPFM tile encoder (Generalizable Pathology Foundation Model).
2
+
3
+ GPFM (Ma et al., 2024; ``birkhoffkiki/GPFM``) is a ``ViT-L/14`` DINOv2 tile
4
+ encoder (embedding dim 1024, patch_size 14). Weights are openly available under
5
+ the MIT license at ``majiabo/GPFM`` but ship as a standalone ``GPFM.pth``
6
+ checkpoint, so — unlike ``hf-hub:`` timm presets — we build the timm arch
7
+ unpretrained and ``load_state_dict`` the downloaded checkpoint (the MOOZY
8
+ loading pattern).
9
+
10
+ The published ``GPFM.pth`` is a bare DINOv2 ``state_dict`` (``cls_token``,
11
+ ``pos_embed``, ``blocks.*``, …) that loads into
12
+ ``vit_large_patch14_dinov2.lvd142m`` with ``strict=True`` and zero missing /
13
+ unexpected keys (verified on the real weights; see
14
+ ``tests/test_gpfm_genbio_heavy.py``). The loader below still defensively unwraps
15
+ common checkpoint wrappers (``{"model": ...}`` / ``{"teacher": ...}`` /
16
+ ``{"student": ...}`` / ``{"state_dict": ...}``) and strips ``module.`` /
17
+ ``backbone.`` prefixes so a re-exported checkpoint keeps loading strictly.
18
+ """
19
+
20
+ from typing import Mapping
21
+
22
+ import torch
23
+ from huggingface_hub import hf_hub_download
24
+
25
+ from slide2vec.encoders.base import TimmTileEncoder
26
+ from slide2vec.encoders.registry import register_encoder
27
+
28
+ _HF_REPO_ID = "majiabo/GPFM"
29
+ _HF_CHECKPOINT = "GPFM.pth"
30
+ _CHECKPOINT_WRAPPER_KEYS = ("model", "teacher", "student", "state_dict", "teacher_backbone")
31
+ _STATE_DICT_PREFIXES = ("module.", "backbone.")
32
+
33
+
34
+ def _unwrap_gpfm_state_dict(payload: Mapping[str, object]) -> dict[str, torch.Tensor]:
35
+ """Reduce a GPFM checkpoint payload to a bare ``state_dict``.
36
+
37
+ Unwraps a single common wrapper key if present, then strips ``module.`` /
38
+ ``backbone.`` prefixes off every key. A no-op on the published bare
39
+ ``GPFM.pth`` (no wrapper, no prefixes), so it preserves strict loading there
40
+ while tolerating a re-wrapped/prefixed re-export.
41
+ """
42
+ state: object = payload
43
+ for wrapper in _CHECKPOINT_WRAPPER_KEYS:
44
+ if isinstance(state, Mapping) and wrapper in state and isinstance(state[wrapper], Mapping):
45
+ state = state[wrapper]
46
+ break
47
+ if not isinstance(state, Mapping):
48
+ raise ValueError(
49
+ f"Unexpected GPFM checkpoint payload: expected a state_dict mapping, got {type(state)}"
50
+ )
51
+ cleaned: dict[str, torch.Tensor] = {}
52
+ for key, value in state.items():
53
+ name = key
54
+ for prefix in _STATE_DICT_PREFIXES:
55
+ if name.startswith(prefix):
56
+ name = name[len(prefix):]
57
+ cleaned[name] = value
58
+ return cleaned
59
+
60
+
61
+ @register_encoder(
62
+ "gpfm",
63
+ output_variants={"default": {"encode_dim": 1024}},
64
+ default_output_variant="default",
65
+ input_size=224,
66
+ patch_size=14,
67
+ supported_spacing_um=0.5, # 512px@0.25um (128um FOV) resized to 224 => ~0.5um/px effective (20x), as for UNI
68
+ precision="fp32", # upstream runs plain fp32, no autocast
69
+ source="majiabo/GPFM",
70
+ )
71
+ class GPFM(TimmTileEncoder):
72
+ def __init__(self, *, output_variant: str | None = None):
73
+ super().__init__(
74
+ "vit_large_patch14_dinov2.lvd142m",
75
+ output_variant=output_variant,
76
+ pretrained=False,
77
+ img_size=224,
78
+ init_values=1e-5,
79
+ dynamic_img_size=True,
80
+ )
81
+ checkpoint_path = hf_hub_download(repo_id=_HF_REPO_ID, filename=_HF_CHECKPOINT)
82
+ payload = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
83
+ if not isinstance(payload, Mapping):
84
+ raise ValueError(
85
+ f"Invalid GPFM checkpoint payload: expected a dict, got {type(payload)}"
86
+ )
87
+ state_dict = _unwrap_gpfm_state_dict(payload)
88
+ self._model.load_state_dict(state_dict, strict=True)
89
+ self._model.eval()
@@ -0,0 +1,34 @@
1
+ """mSTAR tile encoder implementation.
2
+
3
+ mSTAR (Wang et al., 2024; ``Innse/mSTAR``) is released as a ``ViT-L/16`` patch
4
+ encoder, not a slide aggregator: the published checkpoint is a per-tile feature
5
+ extractor and slide2vec handles WSI -> coordinates -> per-tile features itself.
6
+ We therefore register it as a **tile** encoder.
7
+
8
+ The weights live in the **gated** Hugging Face repo `Wangyh/mSTAR`
9
+ (``hf-hub:Wangyh/mSTAR``). Loading them requires access approval on Hugging Face
10
+ and an ``HF_TOKEN`` in the environment.
11
+ """
12
+
13
+ from slide2vec.encoders.base import TimmTileEncoder
14
+ from slide2vec.encoders.registry import register_encoder
15
+
16
+
17
+ @register_encoder(
18
+ "mstar",
19
+ output_variants={"default": {"encode_dim": 1024}},
20
+ default_output_variant="default",
21
+ input_size=224,
22
+ patch_size=16,
23
+ supported_spacing_um=0.5, # 256px @ 20x, resized to 224 (per paper)
24
+ precision="fp32", # upstream runs plain fp32, no autocast
25
+ source="Wangyh/mSTAR",
26
+ )
27
+ class mSTAR(TimmTileEncoder):
28
+ def __init__(self, *, output_variant: str | None = None):
29
+ super().__init__(
30
+ "hf-hub:Wangyh/mSTAR",
31
+ output_variant=output_variant,
32
+ init_values=1e-5,
33
+ dynamic_img_size=True,
34
+ )
@@ -41,7 +41,8 @@ def register_encoder(
41
41
  level: str = "tile",
42
42
  tile_encoder: str | None = None,
43
43
  tile_encoder_output_variant: str | None = None,
44
- supported_spacing_um: float | list[float],
44
+ supported_spacing_um: float | list[float] | None,
45
+ default_spacing_um: float | None = None,
45
46
  precision: str = "fp16",
46
47
  source: str = "",
47
48
  ):
@@ -61,7 +62,22 @@ def register_encoder(
61
62
  level: Encoder output level ("tile" or "slide").
62
63
  tile_encoder: Registered tile encoder dependency for slide-level models.
63
64
  tile_encoder_output_variant: Fixed tile-encoder output variant for slide models.
64
- supported_spacing_um: Supported spacing(s) in µm/px.
65
+ supported_spacing_um: The spacing(s) in µm/px the model was trained/validated
66
+ for; :func:`validate_encoder_config` rejects requests outside this set
67
+ unless ``allow_non_recommended_settings=True``. ``None`` marks a
68
+ *spacing-agnostic* encoder (e.g. a natural-image control): the spacing
69
+ check is skipped entirely because no spacing is more "correct" than
70
+ another. Agnostic encoders MUST pair this with an explicit
71
+ ``default_spacing_um`` so name-only selection still resolves a tiling
72
+ spacing.
73
+ default_spacing_um: The single spacing in µm/px used to tile a slide when the
74
+ caller selects this encoder by name without passing an explicit
75
+ ``requested_spacing_um``. Optional: when omitted it is derived from
76
+ ``supported_spacing_um`` if that is a single value. Encoders that
77
+ support a *list* of spacings, or are spacing-agnostic
78
+ (``supported_spacing_um=None``), have no derivable default and must
79
+ declare one here to be selectable with zero config (otherwise
80
+ :func:`resolve_preprocessing_defaults` requires an explicit spacing).
65
81
  precision: Recommended inference precision ("fp16" or "fp32").
66
82
  source: Model source identifier (e.g. HuggingFace hub path).
67
83
  """
@@ -78,6 +94,7 @@ def register_encoder(
78
94
  "tile_encoder": tile_encoder,
79
95
  "tile_encoder_output_variant": tile_encoder_output_variant,
80
96
  "supported_spacing_um": supported_spacing_um,
97
+ "default_spacing_um": default_spacing_um,
81
98
  "precision": precision,
82
99
  "source": source,
83
100
  }
@@ -133,14 +150,13 @@ def resolve_preprocessing_requirements(
133
150
 
134
151
  if level == "tile":
135
152
  input_size = require_encoder_metadata_field(encoder_name, info, "input_size")
136
- spacing_um = require_encoder_metadata_field(
137
- encoder_name,
138
- info,
139
- "supported_spacing_um",
140
- )
153
+ # supported_spacing_um is the *validated* constraint set and may be None
154
+ # (spacing-agnostic). Kept lazy: do NOT resolve a default here — callers
155
+ # that only need the constraint (e.g. tile-size validation) must not trip
156
+ # over a list/agnostic encoder having no single default.
141
157
  return {
142
158
  "tile_size_px": input_size,
143
- "spacing_um": spacing_um,
159
+ "spacing_um": info.get("supported_spacing_um"),
144
160
  "source_encoder": encoder_name,
145
161
  }
146
162
 
@@ -153,35 +169,67 @@ def resolve_preprocessing_requirements(
153
169
  raise AssertionError("unreachable")
154
170
 
155
171
 
172
+ def _resolve_default_spacing(encoder_name: str, info: dict[str, Any]) -> float:
173
+ """Resolve the single spacing (µm/px) an encoder is tiled at by default.
174
+
175
+ Prefers an explicit ``default_spacing_um``. Otherwise derives it from
176
+ ``supported_spacing_um`` when that is a single value. Encoders that support a
177
+ *list* of spacings, or are spacing-agnostic (``supported_spacing_um=None``),
178
+ with no explicit default have no unambiguous tiling spacing and raise — the
179
+ caller must pass ``preprocessing.requested_spacing_um`` (or the encoder must
180
+ declare ``default_spacing_um``).
181
+ """
182
+ explicit = info.get("default_spacing_um")
183
+ if explicit is not None:
184
+ return float(explicit)
185
+
186
+ supported = info.get("supported_spacing_um")
187
+ if isinstance(supported, list):
188
+ unique_spacings: list[float] = []
189
+ for spacing in supported:
190
+ spacing_value = float(spacing)
191
+ if not any(abs(spacing_value - existing) <= 1e-8 for existing in unique_spacings):
192
+ unique_spacings.append(spacing_value)
193
+ if len(unique_spacings) == 1:
194
+ return unique_spacings[0]
195
+ supported_text = ", ".join(f"{s:g}" for s in unique_spacings)
196
+ raise ValueError(
197
+ f"Encoder '{encoder_name}' supports multiple spacings [{supported_text}]; "
198
+ "cannot infer a default requested_spacing_um. Declare default_spacing_um "
199
+ "in its registration or pass preprocessing.requested_spacing_um explicitly."
200
+ )
201
+ if isinstance(supported, (int, float)) and not isinstance(supported, bool):
202
+ return float(supported)
203
+
204
+ raise ValueError(
205
+ f"Encoder '{encoder_name}' is spacing-agnostic (supported_spacing_um=None) but "
206
+ "declares no default_spacing_um; declare default_spacing_um in its registration "
207
+ "or pass preprocessing.requested_spacing_um explicitly."
208
+ )
209
+
210
+
156
211
  def resolve_preprocessing_defaults(
157
212
  encoder_name: str,
158
213
  metadata: dict[str, Any] | None = None,
159
214
  ) -> dict[str, Any]:
160
215
  """Resolve a single unambiguous preprocessing default for an encoder.
161
216
 
162
- This is stricter than :func:`resolve_preprocessing_requirements`: it only
163
- succeeds when the encoder advertises exactly one supported spacing.
217
+ This is stricter than :func:`resolve_preprocessing_requirements`: it resolves
218
+ exactly one tiling spacing (see :func:`_resolve_default_spacing`) and raises
219
+ when the encoder advertises several supported spacings without an explicit
220
+ ``default_spacing_um``.
164
221
  """
165
222
  reqs = resolve_preprocessing_requirements(encoder_name, metadata)
166
- spacing_um = reqs["spacing_um"]
167
- if isinstance(spacing_um, list):
168
- unique_spacings = []
169
- for spacing in spacing_um:
170
- spacing_value = float(spacing)
171
- if not any(abs(spacing_value - existing) <= 1e-8 for existing in unique_spacings):
172
- unique_spacings.append(spacing_value)
173
- if len(unique_spacings) != 1:
174
- supported_text = ", ".join(f"{s:g}" for s in unique_spacings)
175
- raise ValueError(
176
- f"Encoder '{encoder_name}' supports multiple spacings [{supported_text}]; "
177
- "cannot infer a default requested_spacing_um. "
178
- "Pass preprocessing.requested_spacing_um explicitly."
179
- )
180
- spacing_um = unique_spacings[0]
223
+ source_encoder = reqs["source_encoder"]
224
+ # Resolve the default off the *tile* encoder's metadata: slide/patient
225
+ # encoders inherit both tile size and spacing from their tile encoder, so
226
+ # source_encoder already points at the model that carries the spacing fields.
227
+ source_info = encoder_registry.info(source_encoder)
228
+ spacing_um = _resolve_default_spacing(source_encoder, source_info)
181
229
  return {
182
230
  "tile_size_px": int(reqs["tile_size_px"]),
183
231
  "spacing_um": float(spacing_um),
184
- "source_encoder": reqs["source_encoder"],
232
+ "source_encoder": source_encoder,
185
233
  }
186
234
 
187
235
 
@@ -29,6 +29,10 @@ MODEL_NAME_ALIASES = {
29
29
  "prov-gigapath-tile": "gigapath",
30
30
  "prov-gigapath-slide": "gigapath-slide",
31
31
  "kaiko-midnight": "midnight",
32
+ "mstar-slide": "mstar",
33
+ "dinov2": "dinov2-vitb14",
34
+ "dinov2-base": "dinov2-vitb14",
35
+ "dinov2-vitb": "dinov2-vitb14",
32
36
  }
33
37
 
34
38
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slide2vec
3
- Version: 5.2.0
3
+ Version: 5.3.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
@@ -225,7 +225,7 @@ The package writes explicit artifact directories:
225
225
 
226
226
  ### Supported Models
227
227
 
228
- `slide2vec` currently ships preset configs for 17 tile-level models and 3 slide-level models.
228
+ `slide2vec` currently ships preset configs for 22 tile-level models and 3 slide-level models.
229
229
  For the full catalog and preset names, see [`docs/models.md`](docs/models.md).
230
230
 
231
231
  ## CLI
@@ -31,11 +31,15 @@ slide2vec/encoders/registry.py
31
31
  slide2vec/encoders/validation.py
32
32
  slide2vec/encoders/models/__init__.py
33
33
  slide2vec/encoders/models/conch.py
34
+ slide2vec/encoders/models/dinov2.py
35
+ slide2vec/encoders/models/genbio.py
34
36
  slide2vec/encoders/models/gigapath.py
37
+ slide2vec/encoders/models/gpfm.py
35
38
  slide2vec/encoders/models/hibou.py
36
39
  slide2vec/encoders/models/hoptimus.py
37
40
  slide2vec/encoders/models/lunit.py
38
41
  slide2vec/encoders/models/midnight.py
42
+ slide2vec/encoders/models/mstar.py
39
43
  slide2vec/encoders/models/musk.py
40
44
  slide2vec/encoders/models/phikon.py
41
45
  slide2vec/encoders/models/prism.py
@@ -86,7 +90,9 @@ tests/test_attention_extraction.py
86
90
  tests/test_dense_extraction.py
87
91
  tests/test_dense_regions.py
88
92
  tests/test_dense_sliding.py
93
+ tests/test_dinov2_natimage.py
89
94
  tests/test_encoder_registry.py
95
+ tests/test_gpfm_genbio_heavy.py
90
96
  tests/test_hs2p_package_cutover.py
91
97
  tests/test_output_consistency.py
92
98
  tests/test_patch_size_metadata.py