From eb4006f8e69313d174cf0b85a140ae5ff4d90175 Mon Sep 17 00:00:00 2001 From: clement grisi Date: Wed, 18 Mar 2026 02:14:02 +0100 Subject: [PATCH 1/8] split core dependencies from models extra --- Dockerfile | 5 +- Dockerfile.ci | 3 +- README.md | 8 +++ requirements-foundation.in | 31 +++++++++ requirements.in | 38 ++--------- requirements.txt | 19 +++--- setup.cfg | 20 +++--- slide2vec/data/dataset.py | 12 +++- tests/test_dependency_split.py | 119 +++++++++++++++++++++++++++++++++ 9 files changed, 197 insertions(+), 58 deletions(-) create mode 100644 requirements-foundation.in create mode 100644 tests/test_dependency_split.py diff --git a/Dockerfile b/Dockerfile index 8299e8f..559b88a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,16 +42,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /opt/app/ -# you can add any Python dependencies to requirements.in +# core deps live in requirements.in; model runtime extras live in requirements-foundation.in RUN python -m pip install --upgrade pip setuptools pip-tools \ && rm -rf /home/user/.cache/pip # install slide2vec COPY --chown=user:user requirements.in /opt/app/requirements.in +COPY --chown=user:user requirements-foundation.in /opt/app/requirements-foundation.in RUN python -m pip install \ --no-cache-dir \ --no-color \ - --requirement /opt/app/requirements.in \ + --requirement /opt/app/requirements-foundation.in \ && rm -rf /home/user/.cache/pip COPY --chown=user:user slide2vec /opt/app/slide2vec diff --git a/Dockerfile.ci b/Dockerfile.ci index dcc2582..e4d1293 100755 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -48,10 +48,11 @@ RUN python -m pip install --upgrade pip setuptools pip-tools \ && rm -rf /root/.cache/pip COPY --chown=user:user requirements.in /opt/app/requirements.in +COPY --chown=user:user requirements-foundation.in /opt/app/requirements-foundation.in RUN python -m pip install \ --no-cache-dir \ --no-color \ - --requirement /opt/app/requirements.in \ + --requirement /opt/app/requirements-foundation.in \ && rm -rf /root/.cache/pip COPY --chown=user:user slide2vec /opt/app/slide2vec diff --git a/README.md b/README.md index fe4abe7..7c634ac 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,14 @@ pip install slide2vec ``` +Install the full model runtime only when you need embedding/model execution: + +```shell +pip install "slide2vec[models]" +``` + +`slide2vec` now keeps the base install focused on the core package surface and moves the heavier model stack into the optional `models` extra. + ## Python API ```python diff --git a/requirements-foundation.in b/requirements-foundation.in new file mode 100644 index 0000000..39c15eb --- /dev/null +++ b/requirements-foundation.in @@ -0,0 +1,31 @@ +-r requirements.in +torch>=2.3,<2.8 +torchvision>=0.18.0 +einops>=0.8.0 +huggingface-hub>=0.30.0,<1.0 +timm>=1.0.3 +einops-exts>=0.0.4 +transformers>=4.53 +sacremoses +xformers>=0.0.31 + +## Hibou +scipy~=1.8.1 +scikit-image~=0.19.3 + +## MUSK & CONCH +git+https://github.com/lilab-stanford/MUSK.git +git+https://github.com/Mahmoodlab/CONCH.git + +## Prov-GigaPath +torchmetrics>=0.10.3 +fvcore +iopath +webdataset +scikit-survival +scikit-learn +fairscale +packaging==23.2 +ninja==1.11.1.1 +psutil<6 +git+https://github.com/prov-gigapath/prov-gigapath.git diff --git a/requirements.in b/requirements.in index 07c9890..cc7ba4d 100644 --- a/requirements.in +++ b/requirements.in @@ -1,42 +1,14 @@ omegaconf>=2.3.0 h5py -huggingface-hub>=0.30.0,<1.0 +matplotlib numpy<2 pandas pillow rich tqdm -wandb -torch>=2.3,<2.8 -torchvision>=0.18.0 hs2p>=2.0,<3 +torch +torchvision +wandb wholeslidedata<0.0.16 -timm>=1.0.3 -einops>=0.8.0 -einops-exts>=0.0.4 -transformers>=4.53 -sacremoses -environs -xformers>=0.0.31 -matplotlib - -## Hibou -scipy~=1.8.1 -scikit-image~=0.19.3 - -## MUSK & CONCH -git+https://github.com/lilab-stanford/MUSK.git -git+https://github.com/Mahmoodlab/CONCH.git - -## Prov-GigaPath -torchmetrics>=0.10.3 -fvcore -iopath -webdataset -scikit-survival -scikit-learn -fairscale -packaging==23.2 -ninja==1.11.1.1 -psutil<6 -git+https://github.com/prov-gigapath/prov-gigapath.git +einops diff --git a/requirements.txt b/requirements.txt index 7672ed6..2776d7a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,14 @@ -timm -wandb -numpy==1.26.1 +hs2p>=2.0,<3 +omegaconf>=2.3.0 +h5py +matplotlib +numpy<2 pandas pillow rich -einops +torch +torchvision tqdm -omegaconf -wholeslidedata -huggingface_hub -torch==2.1.0 -torchvision==0.16.0 +wandb +wholeslidedata<0.0.16 +einops diff --git a/setup.cfg b/setup.cfg index e5623d6..d14f81b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,30 +19,30 @@ install_requires = hs2p>=2.0,<3 omegaconf h5py - huggingface-hub + matplotlib numpy<2 pandas pillow rich tqdm + torch torchvision + wandb wholeslidedata<0.0.16 - matplotlib - timm - torch - transformers - environs - sacremoses einops - einops-exts - xformers - wandb python_requires = >=3.10 zip_safe = no include_package_data = True [options.extras_require] +models = + huggingface-hub + timm + transformers + sacremoses + einops-exts + xformers testing = pytest>=6.0 pytest-cov>=2.0 diff --git a/slide2vec/data/dataset.py b/slide2vec/data/dataset.py index e0cd731..25cc906 100644 --- a/slide2vec/data/dataset.py +++ b/slide2vec/data/dataset.py @@ -1,16 +1,18 @@ from pathlib import Path -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING, Any, Callable import numpy as np import torch import wholeslidedata as wsd from PIL import Image -from transformers.image_processing_utils import BaseImageProcessor from slide2vec.utils.coordinates import coordinate_arrays, coordinate_matrix if TYPE_CHECKING: from hs2p import TilingResult + from transformers.image_processing_utils import BaseImageProcessor +else: + BaseImageProcessor = Any class TileDataset(torch.utils.data.Dataset): @@ -72,8 +74,12 @@ def __getitem__(self, idx): if self.target_tile_size != self.read_tile_size: tile = tile.resize((self.target_tile_size, self.target_tile_size)) if self.transforms: - if isinstance(self.transforms, BaseImageProcessor): # Hugging Face (`transformer`) + if _is_huggingface_processor(self.transforms): tile = self.transforms(tile, return_tensors="pt")["pixel_values"].squeeze(0) else: # general callable such as torchvision transforms tile = self.transforms(tile) return idx, tile + + +def _is_huggingface_processor(transforms: Any) -> bool: + return callable(transforms) and hasattr(transforms, "preprocess") diff --git a/tests/test_dependency_split.py b/tests/test_dependency_split.py new file mode 100644 index 0000000..a4ac2cb --- /dev/null +++ b/tests/test_dependency_split.py @@ -0,0 +1,119 @@ +import configparser +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SETUP_CFG = ROOT / "setup.cfg" +README = ROOT / "README.md" +CORE_REQUIREMENTS = ROOT / "requirements.in" +CORE_REQUIREMENTS_TXT = ROOT / "requirements.txt" +FOUNDATION_REQUIREMENTS = ROOT / "requirements-foundation.in" + +FOUNDATION_REQUIREMENT_NAMES = { + "huggingface-hub", + "sacremoses", + "timm", + "transformers", + "xformers", +} + +CORE_RUNTIME_REQUIREMENT_NAMES = { + "einops", + "hs2p", + "matplotlib", + "numpy", + "omegaconf", + "pandas", + "pillow", + "rich", + "torch", + "torchvision", + "tqdm", + "wandb", + "wholeslidedata", +} + + +def _load_setup_cfg() -> configparser.ConfigParser: + parser = configparser.ConfigParser() + parser.read(SETUP_CFG, encoding="utf-8") + return parser + + +def _requirement_names(raw_block: str) -> set[str]: + names: set[str] = set() + for line in raw_block.splitlines(): + requirement = line.strip() + if not requirement or requirement.startswith("#") or requirement.startswith("-r "): + continue + match = re.match(r"^[A-Za-z0-9_.-]+", requirement) + assert match is not None, f"Could not parse requirement line: {requirement}" + names.add(match.group(0).replace("_", "-").lower()) + return names + + +def _requirement_lines(raw_block: str) -> dict[str, str]: + lines: dict[str, str] = {} + for raw_line in raw_block.splitlines(): + requirement = raw_line.strip() + if not requirement or requirement.startswith("#") or requirement.startswith("-r "): + continue + match = re.match(r"^[A-Za-z0-9_.-]+", requirement) + assert match is not None, f"Could not parse requirement line: {requirement}" + lines[match.group(0).replace("_", "-").lower()] = requirement + return lines + + +def test_setup_cfg_moves_model_runtime_deps_into_models_extra(): + parser = _load_setup_cfg() + + install_requires = _requirement_names(parser["options"]["install_requires"]) + models_extra = _requirement_names(parser["options.extras_require"]["models"]) + + assert FOUNDATION_REQUIREMENT_NAMES.isdisjoint(install_requires) + assert FOUNDATION_REQUIREMENT_NAMES <= models_extra + assert CORE_RUNTIME_REQUIREMENT_NAMES <= install_requires + + +def test_requirements_files_split_core_from_foundation_runtime(): + core_requirements_text = CORE_REQUIREMENTS.read_text(encoding="utf-8") + foundation_requirements_text = FOUNDATION_REQUIREMENTS.read_text(encoding="utf-8") + core_requirements = _requirement_names(core_requirements_text) + foundation_requirements = _requirement_names(foundation_requirements_text) + core_requirement_lines = _requirement_lines(core_requirements_text) + foundation_requirement_lines = _requirement_lines(foundation_requirements_text) + + assert FOUNDATION_REQUIREMENT_NAMES.isdisjoint(core_requirements) + assert FOUNDATION_REQUIREMENT_NAMES <= foundation_requirements + assert CORE_RUNTIME_REQUIREMENT_NAMES <= core_requirements + assert "-r requirements.in" in foundation_requirements_text + assert core_requirement_lines["torch"] == "torch" + assert core_requirement_lines["torchvision"] == "torchvision" + assert core_requirement_lines["einops"] == "einops" + assert foundation_requirement_lines["torch"] == "torch>=2.3,<2.8" + assert foundation_requirement_lines["torchvision"] == "torchvision>=0.18.0" + assert foundation_requirement_lines["einops"] == "einops>=0.8.0" + + +def test_requirements_txt_matches_generic_core_runtime_requirements(): + requirement_lines = _requirement_lines(CORE_REQUIREMENTS_TXT.read_text(encoding="utf-8")) + + assert requirement_lines["torch"] == "torch" + assert requirement_lines["torchvision"] == "torchvision" + assert requirement_lines["einops"] == "einops" + + +def test_readme_documents_core_and_models_installs(): + readme = README.read_text(encoding="utf-8") + + assert 'pip install slide2vec' in readme + assert 'pip install "slide2vec[models]"' in readme + + +def test_tile_dataset_avoids_runtime_transformers_import_for_type_checks(): + source = (ROOT / "slide2vec" / "data" / "dataset.py").read_text(encoding="utf-8") + + assert "if TYPE_CHECKING:" in source + assert "from transformers.image_processing_utils import BaseImageProcessor" in source + assert "isinstance(self.transforms, BaseImageProcessor)" not in source From 68dd9388bf8c54273b4a9f7fa634d9a560894fb4 Mon Sep 17 00:00:00 2001 From: clement grisi Date: Wed, 18 Mar 2026 12:51:23 +0100 Subject: [PATCH 2/8] lazy-load backend model dependencies --- slide2vec/models/models.py | 79 +++++++++++++++++++++++++++------- tests/test_dependency_split.py | 55 +++++++++++++++++++++++ 2 files changed, 119 insertions(+), 15 deletions(-) diff --git a/slide2vec/models/models.py b/slide2vec/models/models.py index 20faf1e..a4e810d 100644 --- a/slide2vec/models/models.py +++ b/slide2vec/models/models.py @@ -1,17 +1,12 @@ import logging -import timm import torch import torch.nn as nn import torch.nn.functional as F from einops import rearrange from omegaconf import DictConfig -from timm.data import resolve_data_config -from timm.data.constants import IMAGENET_INCEPTION_MEAN, IMAGENET_INCEPTION_STD -from timm.data.transforms_factory import create_transform from torchvision import transforms from torchvision.transforms import v2 -from transformers import AutoImageProcessor, AutoModel import slide2vec.distributed as distributed import slide2vec.models.vision_transformer_dino as vits_dino @@ -23,6 +18,54 @@ logger = logging.getLogger("slide2vec") +def _optional_dependency_error(package: str) -> ImportError: + return ImportError( + f"Optional model dependency '{package}' is required for the selected model backend. " + "Install slide2vec[models] or preinstall the backend-specific dependency in your image." + ) + + +def _import_timm(): + try: + import timm + except ImportError as exc: + raise _optional_dependency_error("timm") from exc + return timm + + +def _import_timm_data_helpers(): + try: + from timm.data import resolve_data_config + from timm.data.transforms_factory import create_transform + except ImportError as exc: + raise _optional_dependency_error("timm") from exc + return resolve_data_config, create_transform + + +def _import_timm_inception_stats(): + try: + from timm.data.constants import IMAGENET_INCEPTION_MEAN, IMAGENET_INCEPTION_STD + except ImportError as exc: + raise _optional_dependency_error("timm") from exc + return IMAGENET_INCEPTION_MEAN, IMAGENET_INCEPTION_STD + + +def _transformers_auto_model(): + try: + from transformers import AutoModel + except ImportError as exc: + raise _optional_dependency_error("transformers") from exc + return AutoModel + + +def _transformers_auto_image_processor(): + try: + from transformers import AutoImageProcessor + except ImportError as exc: + raise _optional_dependency_error("transformers") from exc + return AutoImageProcessor + + def _log_main_process_info(message: str) -> None: if distributed.is_main_process(): logger.info(message) @@ -76,6 +119,7 @@ def _select_mode_embedding(cls_embedding, patch_embeddings, *, mode: str): def _build_timm_hub_encoder(model_name: str, **kwargs): + timm = _import_timm() return timm.create_model(model_name, pretrained=True, **kwargs) @@ -231,9 +275,8 @@ def build_encoder(self): raise NotImplementedError def get_transforms(self): - data_config = resolve_data_config( - self.encoder.pretrained_cfg, model=self.encoder - ) + resolve_data_config, create_transform = _import_timm_data_helpers() + data_config = resolve_data_config(self.encoder.pretrained_cfg, model=self.encoder) transform = create_transform(**data_config) return transform @@ -505,6 +548,7 @@ def __init__(self): super(UNI2, self).__init__() def build_encoder(self): + timm = _import_timm() timm_kwargs = { "img_size": 224, "patch_size": 14, @@ -539,6 +583,7 @@ def __init__(self, mode: str = "cls"): super(Virchow, self).__init__() def build_encoder(self): + timm = _import_timm() encoder = _build_timm_hub_encoder( "hf-hub:paige-ai/Virchow", mlp_layer=timm.layers.SwiGLUPacked, @@ -565,6 +610,7 @@ def __init__(self, mode: str = "cls"): super(Virchow2, self).__init__() def build_encoder(self): + timm = _import_timm() encoder = _build_timm_hub_encoder( "hf-hub:paige-ai/Virchow2", mlp_layer=timm.layers.SwiGLUPacked, @@ -643,6 +689,7 @@ def __init__(self, mode: str = "cls"): super(Hoptimus0Mini, self).__init__() def build_encoder(self): + timm = _import_timm() encoder = _build_timm_hub_encoder( "hf-hub:bioptimus/H0-mini", mlp_layer=timm.layers.SwiGLUPacked, @@ -691,6 +738,7 @@ def __init__(self): def build_encoder(self): from musk import utils as musk_utils + timm = _import_timm() encoder = timm.create_model("musk_large_patch16_384") musk_utils.load_model_and_may_interpolate( "hf_hub:xiangjx/musk", encoder, "model|module", "" @@ -698,6 +746,7 @@ def build_encoder(self): return encoder def get_transforms(self): + IMAGENET_INCEPTION_MEAN, IMAGENET_INCEPTION_STD = _import_timm_inception_stats() return transforms.Compose( [ transforms.Resize(384, interpolation=3, antialias=True), @@ -726,10 +775,10 @@ def __init__(self): super(PhikonV2, self).__init__() def build_encoder(self): - return AutoModel.from_pretrained("owkin/phikon-v2", trust_remote_code=True) + return _transformers_auto_model().from_pretrained("owkin/phikon-v2", trust_remote_code=True) def get_transforms(self): - return AutoImageProcessor.from_pretrained("owkin/phikon-v2", trust_remote_code=True) + return _transformers_auto_image_processor().from_pretrained("owkin/phikon-v2", trust_remote_code=True) def forward(self, x): embedding = self.encoder(x).last_hidden_state[:, 0, :] @@ -781,7 +830,7 @@ def __init__(self): super(Midnight12k, self).__init__() def build_encoder(self): - return AutoModel.from_pretrained('kaiko-ai/midnight') + return _transformers_auto_model().from_pretrained('kaiko-ai/midnight') def get_transforms(self): return v2.Compose( @@ -810,10 +859,10 @@ def __init__(self, arch="hibou-b"): def build_encoder(self): model = f"histai/{self.arch}" - return AutoModel.from_pretrained(model, trust_remote_code=True) + return _transformers_auto_model().from_pretrained(model, trust_remote_code=True) def get_transforms(self): - return AutoImageProcessor.from_pretrained( + return _transformers_auto_image_processor().from_pretrained( f"histai/{self.arch}", trust_remote_code=True ) @@ -907,7 +956,7 @@ def __init__(self): self.features_dim = 768 def build_encoders(self): - self.slide_encoder = AutoModel.from_pretrained( + self.slide_encoder = _transformers_auto_model().from_pretrained( "MahmoodLab/TITAN", trust_remote_code=True ) self.tile_encoder, self.eval_transform = self.slide_encoder.return_conch() @@ -935,7 +984,7 @@ def __init__(self, return_latents: bool = False): self.return_latents = return_latents def build_encoders(self): - self.slide_encoder = AutoModel.from_pretrained( + self.slide_encoder = _transformers_auto_model().from_pretrained( "paige-ai/PRISM", trust_remote_code=True ) self.tile_encoder = Virchow(mode="full") diff --git a/tests/test_dependency_split.py b/tests/test_dependency_split.py index a4ac2cb..e64687b 100644 --- a/tests/test_dependency_split.py +++ b/tests/test_dependency_split.py @@ -1,6 +1,13 @@ +import ast +import builtins +import importlib import configparser import re +import sys from pathlib import Path +from types import ModuleType + +import pytest ROOT = Path(__file__).resolve().parents[1] @@ -65,6 +72,17 @@ def _requirement_lines(raw_block: str) -> dict[str, str]: return lines +def _top_level_imported_modules(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8")) + modules: set[str] = set() + for node in tree.body: + if isinstance(node, ast.Import): + modules.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + modules.add(node.module.split(".")[0]) + return modules + + def test_setup_cfg_moves_model_runtime_deps_into_models_extra(): parser = _load_setup_cfg() @@ -117,3 +135,40 @@ def test_tile_dataset_avoids_runtime_transformers_import_for_type_checks(): assert "if TYPE_CHECKING:" in source assert "from transformers.image_processing_utils import BaseImageProcessor" in source assert "isinstance(self.transforms, BaseImageProcessor)" not in source + + +def test_models_module_defers_timm_and_transformers_top_level_imports(): + imported_modules = _top_level_imported_modules(ROOT / "slide2vec" / "models" / "models.py") + + assert "timm" not in imported_modules + assert "transformers" not in imported_modules + + +def test_models_module_imports_without_timm_or_transformers(monkeypatch): + original_import = builtins.__import__ + + for name in list(sys.modules): + if name == "slide2vec.models" or name.startswith("slide2vec.models."): + sys.modules.pop(name, None) + + fake_einops = ModuleType("einops") + fake_einops.rearrange = lambda *args, **kwargs: None + monkeypatch.setitem(sys.modules, "einops", fake_einops) + fake_omegaconf = ModuleType("omegaconf") + fake_omegaconf.DictConfig = object + monkeypatch.setitem(sys.modules, "omegaconf", fake_omegaconf) + + def guarded_import(name, globals=None, locals=None, fromlist=(), level=0): + if name.split(".")[0] in {"timm", "transformers"}: + raise AssertionError(f"unexpected eager import: {name}") + return original_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", guarded_import) + + try: + module = importlib.import_module("slide2vec.models.models") + except ModuleNotFoundError as exc: + assert exc.name not in {"timm", "transformers"} + pytest.skip(f"core dependency {exc.name} is not installed in this test environment") + + assert hasattr(module, "ModelFactory") From afb232d100f548946e57cbd6e1ec24917b952fdd Mon Sep 17 00:00:00 2001 From: clement grisi Date: Wed, 18 Mar 2026 12:59:38 +0100 Subject: [PATCH 3/8] make timm a core dependency --- requirements-foundation.in | 1 - requirements.in | 1 + requirements.txt | 1 + setup.cfg | 2 +- tests/test_dependency_split.py | 12 ++++++------ 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/requirements-foundation.in b/requirements-foundation.in index 39c15eb..fd3a983 100644 --- a/requirements-foundation.in +++ b/requirements-foundation.in @@ -3,7 +3,6 @@ torch>=2.3,<2.8 torchvision>=0.18.0 einops>=0.8.0 huggingface-hub>=0.30.0,<1.0 -timm>=1.0.3 einops-exts>=0.0.4 transformers>=4.53 sacremoses diff --git a/requirements.in b/requirements.in index cc7ba4d..244f2db 100644 --- a/requirements.in +++ b/requirements.in @@ -12,3 +12,4 @@ torchvision wandb wholeslidedata<0.0.16 einops +timm diff --git a/requirements.txt b/requirements.txt index 2776d7a..a18c4df 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,4 @@ tqdm wandb wholeslidedata<0.0.16 einops +timm diff --git a/setup.cfg b/setup.cfg index d14f81b..e604ae4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,6 +30,7 @@ install_requires = wandb wholeslidedata<0.0.16 einops + timm python_requires = >=3.10 zip_safe = no @@ -38,7 +39,6 @@ include_package_data = True [options.extras_require] models = huggingface-hub - timm transformers sacremoses einops-exts diff --git a/tests/test_dependency_split.py b/tests/test_dependency_split.py index e64687b..da5adbd 100644 --- a/tests/test_dependency_split.py +++ b/tests/test_dependency_split.py @@ -20,7 +20,6 @@ FOUNDATION_REQUIREMENT_NAMES = { "huggingface-hub", "sacremoses", - "timm", "transformers", "xformers", } @@ -37,6 +36,7 @@ "torch", "torchvision", "tqdm", + "timm", "wandb", "wholeslidedata", } @@ -120,6 +120,7 @@ def test_requirements_txt_matches_generic_core_runtime_requirements(): assert requirement_lines["torch"] == "torch" assert requirement_lines["torchvision"] == "torchvision" assert requirement_lines["einops"] == "einops" + assert requirement_lines["timm"] == "timm" def test_readme_documents_core_and_models_installs(): @@ -137,14 +138,13 @@ def test_tile_dataset_avoids_runtime_transformers_import_for_type_checks(): assert "isinstance(self.transforms, BaseImageProcessor)" not in source -def test_models_module_defers_timm_and_transformers_top_level_imports(): +def test_models_module_defers_transformers_top_level_imports(): imported_modules = _top_level_imported_modules(ROOT / "slide2vec" / "models" / "models.py") - assert "timm" not in imported_modules assert "transformers" not in imported_modules -def test_models_module_imports_without_timm_or_transformers(monkeypatch): +def test_models_module_imports_without_transformers(monkeypatch): original_import = builtins.__import__ for name in list(sys.modules): @@ -159,7 +159,7 @@ def test_models_module_imports_without_timm_or_transformers(monkeypatch): monkeypatch.setitem(sys.modules, "omegaconf", fake_omegaconf) def guarded_import(name, globals=None, locals=None, fromlist=(), level=0): - if name.split(".")[0] in {"timm", "transformers"}: + if name.split(".")[0] == "transformers": raise AssertionError(f"unexpected eager import: {name}") return original_import(name, globals, locals, fromlist, level) @@ -168,7 +168,7 @@ def guarded_import(name, globals=None, locals=None, fromlist=(), level=0): try: module = importlib.import_module("slide2vec.models.models") except ModuleNotFoundError as exc: - assert exc.name not in {"timm", "transformers"} + assert exc.name != "transformers" pytest.skip(f"core dependency {exc.name} is not installed in this test environment") assert hasattr(module, "ModelFactory") From c947c174afd83cb0de950a0b546a84fc41a6ca04 Mon Sep 17 00:00:00 2001 From: clement grisi Date: Wed, 18 Mar 2026 13:16:14 +0100 Subject: [PATCH 4/8] restore timm floor in models overlay --- requirements-foundation.in | 1 + tests/test_dependency_split.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/requirements-foundation.in b/requirements-foundation.in index fd3a983..d0e41d5 100644 --- a/requirements-foundation.in +++ b/requirements-foundation.in @@ -2,6 +2,7 @@ torch>=2.3,<2.8 torchvision>=0.18.0 einops>=0.8.0 +timm>=1.0.3 huggingface-hub>=0.30.0,<1.0 einops-exts>=0.0.4 transformers>=4.53 diff --git a/tests/test_dependency_split.py b/tests/test_dependency_split.py index da5adbd..ce9d1ad 100644 --- a/tests/test_dependency_split.py +++ b/tests/test_dependency_split.py @@ -109,9 +109,11 @@ def test_requirements_files_split_core_from_foundation_runtime(): assert core_requirement_lines["torch"] == "torch" assert core_requirement_lines["torchvision"] == "torchvision" assert core_requirement_lines["einops"] == "einops" + assert core_requirement_lines["timm"] == "timm" assert foundation_requirement_lines["torch"] == "torch>=2.3,<2.8" assert foundation_requirement_lines["torchvision"] == "torchvision>=0.18.0" assert foundation_requirement_lines["einops"] == "einops>=0.8.0" + assert foundation_requirement_lines["timm"] == "timm>=1.0.3" def test_requirements_txt_matches_generic_core_runtime_requirements(): From eebdbe936721da493f3bc6b0d8b4e7efb8a9a23e Mon Sep 17 00:00:00 2001 From: clement grisi Date: Wed, 18 Mar 2026 13:23:24 +0100 Subject: [PATCH 5/8] remove timm lazy-loading wrappers --- slide2vec/models/models.py | 37 ++++--------------------------------- 1 file changed, 4 insertions(+), 33 deletions(-) diff --git a/slide2vec/models/models.py b/slide2vec/models/models.py index a4e810d..adaa225 100644 --- a/slide2vec/models/models.py +++ b/slide2vec/models/models.py @@ -1,10 +1,14 @@ import logging +import timm import torch import torch.nn as nn import torch.nn.functional as F from einops import rearrange from omegaconf import DictConfig +from timm.data import resolve_data_config +from timm.data.constants import IMAGENET_INCEPTION_MEAN, IMAGENET_INCEPTION_STD +from timm.data.transforms_factory import create_transform from torchvision import transforms from torchvision.transforms import v2 @@ -25,31 +29,6 @@ def _optional_dependency_error(package: str) -> ImportError: ) -def _import_timm(): - try: - import timm - except ImportError as exc: - raise _optional_dependency_error("timm") from exc - return timm - - -def _import_timm_data_helpers(): - try: - from timm.data import resolve_data_config - from timm.data.transforms_factory import create_transform - except ImportError as exc: - raise _optional_dependency_error("timm") from exc - return resolve_data_config, create_transform - - -def _import_timm_inception_stats(): - try: - from timm.data.constants import IMAGENET_INCEPTION_MEAN, IMAGENET_INCEPTION_STD - except ImportError as exc: - raise _optional_dependency_error("timm") from exc - return IMAGENET_INCEPTION_MEAN, IMAGENET_INCEPTION_STD - - def _transformers_auto_model(): try: from transformers import AutoModel @@ -119,7 +98,6 @@ def _select_mode_embedding(cls_embedding, patch_embeddings, *, mode: str): def _build_timm_hub_encoder(model_name: str, **kwargs): - timm = _import_timm() return timm.create_model(model_name, pretrained=True, **kwargs) @@ -275,7 +253,6 @@ def build_encoder(self): raise NotImplementedError def get_transforms(self): - resolve_data_config, create_transform = _import_timm_data_helpers() data_config = resolve_data_config(self.encoder.pretrained_cfg, model=self.encoder) transform = create_transform(**data_config) return transform @@ -548,7 +525,6 @@ def __init__(self): super(UNI2, self).__init__() def build_encoder(self): - timm = _import_timm() timm_kwargs = { "img_size": 224, "patch_size": 14, @@ -583,7 +559,6 @@ def __init__(self, mode: str = "cls"): super(Virchow, self).__init__() def build_encoder(self): - timm = _import_timm() encoder = _build_timm_hub_encoder( "hf-hub:paige-ai/Virchow", mlp_layer=timm.layers.SwiGLUPacked, @@ -610,7 +585,6 @@ def __init__(self, mode: str = "cls"): super(Virchow2, self).__init__() def build_encoder(self): - timm = _import_timm() encoder = _build_timm_hub_encoder( "hf-hub:paige-ai/Virchow2", mlp_layer=timm.layers.SwiGLUPacked, @@ -689,7 +663,6 @@ def __init__(self, mode: str = "cls"): super(Hoptimus0Mini, self).__init__() def build_encoder(self): - timm = _import_timm() encoder = _build_timm_hub_encoder( "hf-hub:bioptimus/H0-mini", mlp_layer=timm.layers.SwiGLUPacked, @@ -738,7 +711,6 @@ def __init__(self): def build_encoder(self): from musk import utils as musk_utils - timm = _import_timm() encoder = timm.create_model("musk_large_patch16_384") musk_utils.load_model_and_may_interpolate( "hf_hub:xiangjx/musk", encoder, "model|module", "" @@ -746,7 +718,6 @@ def build_encoder(self): return encoder def get_transforms(self): - IMAGENET_INCEPTION_MEAN, IMAGENET_INCEPTION_STD = _import_timm_inception_stats() return transforms.Compose( [ transforms.Resize(384, interpolation=3, antialias=True), From 4d611fced4c0ab23e9b78ae3b131aeb287d6e3cc Mon Sep 17 00:00:00 2001 From: clement grisi Date: Wed, 18 Mar 2026 13:30:21 +0100 Subject: [PATCH 6/8] make transformers a core dependency --- requirements.in | 1 + requirements.txt | 1 + setup.cfg | 2 +- slide2vec/data/dataset.py | 12 ++------ slide2vec/models/models.py | 38 +++++------------------- tests/test_dependency_split.py | 53 +++++----------------------------- 6 files changed, 21 insertions(+), 86 deletions(-) diff --git a/requirements.in b/requirements.in index 244f2db..dd2bafd 100644 --- a/requirements.in +++ b/requirements.in @@ -9,6 +9,7 @@ tqdm hs2p>=2.0,<3 torch torchvision +transformers wandb wholeslidedata<0.0.16 einops diff --git a/requirements.txt b/requirements.txt index a18c4df..3fca394 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ pillow rich torch torchvision +transformers tqdm wandb wholeslidedata<0.0.16 diff --git a/setup.cfg b/setup.cfg index e604ae4..6bd9078 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,6 +27,7 @@ install_requires = tqdm torch torchvision + transformers wandb wholeslidedata<0.0.16 einops @@ -39,7 +40,6 @@ include_package_data = True [options.extras_require] models = huggingface-hub - transformers sacremoses einops-exts xformers diff --git a/slide2vec/data/dataset.py b/slide2vec/data/dataset.py index 25cc906..e0cd731 100644 --- a/slide2vec/data/dataset.py +++ b/slide2vec/data/dataset.py @@ -1,18 +1,16 @@ from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Callable import numpy as np import torch import wholeslidedata as wsd from PIL import Image +from transformers.image_processing_utils import BaseImageProcessor from slide2vec.utils.coordinates import coordinate_arrays, coordinate_matrix if TYPE_CHECKING: from hs2p import TilingResult - from transformers.image_processing_utils import BaseImageProcessor -else: - BaseImageProcessor = Any class TileDataset(torch.utils.data.Dataset): @@ -74,12 +72,8 @@ def __getitem__(self, idx): if self.target_tile_size != self.read_tile_size: tile = tile.resize((self.target_tile_size, self.target_tile_size)) if self.transforms: - if _is_huggingface_processor(self.transforms): + if isinstance(self.transforms, BaseImageProcessor): # Hugging Face (`transformer`) tile = self.transforms(tile, return_tensors="pt")["pixel_values"].squeeze(0) else: # general callable such as torchvision transforms tile = self.transforms(tile) return idx, tile - - -def _is_huggingface_processor(transforms: Any) -> bool: - return callable(transforms) and hasattr(transforms, "preprocess") diff --git a/slide2vec/models/models.py b/slide2vec/models/models.py index adaa225..3f17d17 100644 --- a/slide2vec/models/models.py +++ b/slide2vec/models/models.py @@ -9,6 +9,7 @@ from timm.data import resolve_data_config from timm.data.constants import IMAGENET_INCEPTION_MEAN, IMAGENET_INCEPTION_STD from timm.data.transforms_factory import create_transform +from transformers import AutoImageProcessor, AutoModel from torchvision import transforms from torchvision.transforms import v2 @@ -22,29 +23,6 @@ logger = logging.getLogger("slide2vec") -def _optional_dependency_error(package: str) -> ImportError: - return ImportError( - f"Optional model dependency '{package}' is required for the selected model backend. " - "Install slide2vec[models] or preinstall the backend-specific dependency in your image." - ) - - -def _transformers_auto_model(): - try: - from transformers import AutoModel - except ImportError as exc: - raise _optional_dependency_error("transformers") from exc - return AutoModel - - -def _transformers_auto_image_processor(): - try: - from transformers import AutoImageProcessor - except ImportError as exc: - raise _optional_dependency_error("transformers") from exc - return AutoImageProcessor - - def _log_main_process_info(message: str) -> None: if distributed.is_main_process(): logger.info(message) @@ -746,10 +724,10 @@ def __init__(self): super(PhikonV2, self).__init__() def build_encoder(self): - return _transformers_auto_model().from_pretrained("owkin/phikon-v2", trust_remote_code=True) + return AutoModel.from_pretrained("owkin/phikon-v2", trust_remote_code=True) def get_transforms(self): - return _transformers_auto_image_processor().from_pretrained("owkin/phikon-v2", trust_remote_code=True) + return AutoImageProcessor.from_pretrained("owkin/phikon-v2", trust_remote_code=True) def forward(self, x): embedding = self.encoder(x).last_hidden_state[:, 0, :] @@ -801,7 +779,7 @@ def __init__(self): super(Midnight12k, self).__init__() def build_encoder(self): - return _transformers_auto_model().from_pretrained('kaiko-ai/midnight') + return AutoModel.from_pretrained('kaiko-ai/midnight') def get_transforms(self): return v2.Compose( @@ -830,10 +808,10 @@ def __init__(self, arch="hibou-b"): def build_encoder(self): model = f"histai/{self.arch}" - return _transformers_auto_model().from_pretrained(model, trust_remote_code=True) + return AutoModel.from_pretrained(model, trust_remote_code=True) def get_transforms(self): - return _transformers_auto_image_processor().from_pretrained( + return AutoImageProcessor.from_pretrained( f"histai/{self.arch}", trust_remote_code=True ) @@ -927,7 +905,7 @@ def __init__(self): self.features_dim = 768 def build_encoders(self): - self.slide_encoder = _transformers_auto_model().from_pretrained( + self.slide_encoder = AutoModel.from_pretrained( "MahmoodLab/TITAN", trust_remote_code=True ) self.tile_encoder, self.eval_transform = self.slide_encoder.return_conch() @@ -955,7 +933,7 @@ def __init__(self, return_latents: bool = False): self.return_latents = return_latents def build_encoders(self): - self.slide_encoder = _transformers_auto_model().from_pretrained( + self.slide_encoder = AutoModel.from_pretrained( "paige-ai/PRISM", trust_remote_code=True ) self.tile_encoder = Virchow(mode="full") diff --git a/tests/test_dependency_split.py b/tests/test_dependency_split.py index ce9d1ad..ed3bc81 100644 --- a/tests/test_dependency_split.py +++ b/tests/test_dependency_split.py @@ -1,14 +1,7 @@ import ast -import builtins -import importlib import configparser import re -import sys from pathlib import Path -from types import ModuleType - -import pytest - ROOT = Path(__file__).resolve().parents[1] SETUP_CFG = ROOT / "setup.cfg" @@ -20,7 +13,6 @@ FOUNDATION_REQUIREMENT_NAMES = { "huggingface-hub", "sacremoses", - "transformers", "xformers", } @@ -35,6 +27,7 @@ "rich", "torch", "torchvision", + "transformers", "tqdm", "timm", "wandb", @@ -110,10 +103,12 @@ def test_requirements_files_split_core_from_foundation_runtime(): assert core_requirement_lines["torchvision"] == "torchvision" assert core_requirement_lines["einops"] == "einops" assert core_requirement_lines["timm"] == "timm" + assert core_requirement_lines["transformers"] == "transformers" assert foundation_requirement_lines["torch"] == "torch>=2.3,<2.8" assert foundation_requirement_lines["torchvision"] == "torchvision>=0.18.0" assert foundation_requirement_lines["einops"] == "einops>=0.8.0" assert foundation_requirement_lines["timm"] == "timm>=1.0.3" + assert foundation_requirement_lines["transformers"] == "transformers>=4.53" def test_requirements_txt_matches_generic_core_runtime_requirements(): @@ -123,6 +118,7 @@ def test_requirements_txt_matches_generic_core_runtime_requirements(): assert requirement_lines["torchvision"] == "torchvision" assert requirement_lines["einops"] == "einops" assert requirement_lines["timm"] == "timm" + assert requirement_lines["transformers"] == "transformers" def test_readme_documents_core_and_models_installs(): @@ -132,45 +128,10 @@ def test_readme_documents_core_and_models_installs(): assert 'pip install "slide2vec[models]"' in readme -def test_tile_dataset_avoids_runtime_transformers_import_for_type_checks(): +def test_tile_dataset_uses_direct_transformers_type_check(): source = (ROOT / "slide2vec" / "data" / "dataset.py").read_text(encoding="utf-8") - assert "if TYPE_CHECKING:" in source assert "from transformers.image_processing_utils import BaseImageProcessor" in source - assert "isinstance(self.transforms, BaseImageProcessor)" not in source - - -def test_models_module_defers_transformers_top_level_imports(): + assert "isinstance(self.transforms, BaseImageProcessor)" in source imported_modules = _top_level_imported_modules(ROOT / "slide2vec" / "models" / "models.py") - - assert "transformers" not in imported_modules - - -def test_models_module_imports_without_transformers(monkeypatch): - original_import = builtins.__import__ - - for name in list(sys.modules): - if name == "slide2vec.models" or name.startswith("slide2vec.models."): - sys.modules.pop(name, None) - - fake_einops = ModuleType("einops") - fake_einops.rearrange = lambda *args, **kwargs: None - monkeypatch.setitem(sys.modules, "einops", fake_einops) - fake_omegaconf = ModuleType("omegaconf") - fake_omegaconf.DictConfig = object - monkeypatch.setitem(sys.modules, "omegaconf", fake_omegaconf) - - def guarded_import(name, globals=None, locals=None, fromlist=(), level=0): - if name.split(".")[0] == "transformers": - raise AssertionError(f"unexpected eager import: {name}") - return original_import(name, globals, locals, fromlist, level) - - monkeypatch.setattr(builtins, "__import__", guarded_import) - - try: - module = importlib.import_module("slide2vec.models.models") - except ModuleNotFoundError as exc: - assert exc.name != "transformers" - pytest.skip(f"core dependency {exc.name} is not installed in this test environment") - - assert hasattr(module, "ModelFactory") + assert "transformers" in imported_modules From 378a6bfabae6de5f6f1c8c5576b714d192bfb115 Mon Sep 17 00:00:00 2001 From: clement grisi Date: Wed, 18 Mar 2026 13:31:49 +0100 Subject: [PATCH 7/8] rename models overlay requirements file --- Dockerfile | 6 +++--- Dockerfile.ci | 4 ++-- requirements-foundation.in => requirements-models.in | 0 tests/test_dependency_split.py | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) rename requirements-foundation.in => requirements-models.in (100%) diff --git a/Dockerfile b/Dockerfile index 559b88a..6d920d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,17 +42,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /opt/app/ -# core deps live in requirements.in; model runtime extras live in requirements-foundation.in +# core deps live in requirements.in; model runtime extras live in requirements-models.in RUN python -m pip install --upgrade pip setuptools pip-tools \ && rm -rf /home/user/.cache/pip # install slide2vec COPY --chown=user:user requirements.in /opt/app/requirements.in -COPY --chown=user:user requirements-foundation.in /opt/app/requirements-foundation.in +COPY --chown=user:user requirements-models.in /opt/app/requirements-models.in RUN python -m pip install \ --no-cache-dir \ --no-color \ - --requirement /opt/app/requirements-foundation.in \ + --requirement /opt/app/requirements-models.in \ && rm -rf /home/user/.cache/pip COPY --chown=user:user slide2vec /opt/app/slide2vec diff --git a/Dockerfile.ci b/Dockerfile.ci index e4d1293..96a27db 100755 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -48,11 +48,11 @@ RUN python -m pip install --upgrade pip setuptools pip-tools \ && rm -rf /root/.cache/pip COPY --chown=user:user requirements.in /opt/app/requirements.in -COPY --chown=user:user requirements-foundation.in /opt/app/requirements-foundation.in +COPY --chown=user:user requirements-models.in /opt/app/requirements-models.in RUN python -m pip install \ --no-cache-dir \ --no-color \ - --requirement /opt/app/requirements-foundation.in \ + --requirement /opt/app/requirements-models.in \ && rm -rf /root/.cache/pip COPY --chown=user:user slide2vec /opt/app/slide2vec diff --git a/requirements-foundation.in b/requirements-models.in similarity index 100% rename from requirements-foundation.in rename to requirements-models.in diff --git a/tests/test_dependency_split.py b/tests/test_dependency_split.py index ed3bc81..a41ead4 100644 --- a/tests/test_dependency_split.py +++ b/tests/test_dependency_split.py @@ -8,7 +8,7 @@ README = ROOT / "README.md" CORE_REQUIREMENTS = ROOT / "requirements.in" CORE_REQUIREMENTS_TXT = ROOT / "requirements.txt" -FOUNDATION_REQUIREMENTS = ROOT / "requirements-foundation.in" +MODELS_REQUIREMENTS = ROOT / "requirements-models.in" FOUNDATION_REQUIREMENT_NAMES = { "huggingface-hub", @@ -89,7 +89,7 @@ def test_setup_cfg_moves_model_runtime_deps_into_models_extra(): def test_requirements_files_split_core_from_foundation_runtime(): core_requirements_text = CORE_REQUIREMENTS.read_text(encoding="utf-8") - foundation_requirements_text = FOUNDATION_REQUIREMENTS.read_text(encoding="utf-8") + foundation_requirements_text = MODELS_REQUIREMENTS.read_text(encoding="utf-8") core_requirements = _requirement_names(core_requirements_text) foundation_requirements = _requirement_names(foundation_requirements_text) core_requirement_lines = _requirement_lines(core_requirements_text) From 906604dcc194a5f087977b2c083adc9c09c36a1c Mon Sep 17 00:00:00 2001 From: clement grisi Date: Wed, 18 Mar 2026 14:05:55 +0100 Subject: [PATCH 8/8] restore environs in models overlay --- requirements-models.in | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-models.in b/requirements-models.in index d0e41d5..b55e3e9 100644 --- a/requirements-models.in +++ b/requirements-models.in @@ -4,6 +4,7 @@ torchvision>=0.18.0 einops>=0.8.0 timm>=1.0.3 huggingface-hub>=0.30.0,<1.0 +environs einops-exts>=0.0.4 transformers>=4.53 sacremoses