diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..beadbb4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,41 @@ +version: 2 + +updates: + # The model stack moves fast and the floors in pyproject.toml are open-ended, so a + # breaking major (transformers 4 -> 5, for instance) reaches users silently today. + # Grouping keeps the noise to one PR per week per ecosystem. + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + model-stack: + patterns: + - torch + - lightning + - timm + - peft + - transformers + - safetensors + - huggingface_hub + scientific-python: + patterns: + - numpy + - pandas + - pillow + dev-tooling: + patterns: + - pytest* + - ruff + - build + - twine + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a09b72f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,100 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + # Scoped to tests/ for now. Running this over src/ and examples/ currently + # reports 4 findings and would reformat 8 files, so widening the scope is a + # separate change rather than something buried in this one. + - run: pipx run ruff check tests/ + - run: pipx run ruff format --check tests/ + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Must stay in sync with `requires-python` in pyproject.toml. + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + # The CPU wheels keep the runner from pulling the ~2.5 GB CUDA build. torch and + # torchvision must be installed together from the same index: timm and lightning + # otherwise pull the default torchvision from PyPI, and the mismatched pair fails + # at import with "operator torchvision::nms does not exist". + - name: Install CPU-only torch + run: pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu + + - name: Install the package + run: pip install .[test] + + # The model weights are gated on the Hugging Face Hub, so CI has no token and + # must never reach for them. Every test runs against the packaged assets or a + # randomly-initialised module, which is what keeps this job runnable on a fork. + - name: Run tests + run: pytest -q + env: + HF_HUB_OFFLINE: "1" + + import-check: + # Installs from a built wheel rather than the source tree, so a missing entry in + # package-data (the assets the model reads at import time) fails here. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu + - run: pip install build && python -m build + - name: Install the wheel outside the source tree + run: | + pip install dist/*.whl + cd /tmp && python -c " + import deepspotm + from deepspotm.config import config + from deepspotm.modules import StructureExpression + assert config.ALPHABET_PATH.is_file(), 'packaged vocabulary missing from wheel' + assert len(StructureExpression().gene_names_ordered) == 19338 + print('wheel import OK:', deepspotm.__all__) + " + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install build twine + - run: python -m build + - name: Check distribution metadata + run: twine check --strict dist/* + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f8b9c95 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +name: Release + +# Publishes to PyPI via Trusted Publishing (OIDC), so no API token is ever stored in +# the repository. Before the first run, register this workflow as a trusted publisher +# at https://pypi.org/manage/account/publishing/ using: +# owner: ratschlab repo: DeepSpotM workflow: release.yml environment: pypi +# +# Tag a release with `git tag v1.0.0 && git push --tags`. The tag must match the +# version in pyproject.toml; the check-version job enforces that. + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + target: + description: Where to publish + required: true + default: testpypi + type: choice + options: [testpypi, pypi] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install build twine + - run: python -m build + - run: twine check --strict dist/* + + - name: Tag must match the project version + if: startsWith(github.ref, 'refs/tags/v') + run: | + project_version=$(python -c " + import tomllib, pathlib + print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version']) + ") + tag_version="${GITHUB_REF_NAME#v}" + if [ "$project_version" != "$tag_version" ]; then + echo "tag $tag_version does not match pyproject version $project_version" + exit 1 + fi + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + testpypi: + needs: build + if: github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi' + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/deepspotm + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + pypi: + needs: build + if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.target == 'pypi') + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/deepspotm + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/examples/predict_tcga_skcm.ipynb b/examples/predict_tcga_skcm.ipynb index a575c2b..e96c458 100644 --- a/examples/predict_tcga_skcm.ipynb +++ b/examples/predict_tcga_skcm.ipynb @@ -35,7 +35,7 @@ "> released model in zero-shot mode, so the values are illustrative and the maps\n", "> will look softer than the finetuned atlas.\n", "\n", - "Requirements are `pip install deepspotm pyvips matplotlib`. pyvips needs the system\n", + "Install with `pip install git+https://github.com/ratschlab/DeepSpotM.git` plus `pip install pyvips matplotlib`. pyvips needs the system\n", "libvips with OpenSlide support. A GPU is recommended. The model weights are gated on\n", "the Hugging Face Hub, so request access and log in first with `huggingface-cli login`.\n", "Note that pyvips must be imported before torch." diff --git a/pyproject.toml b/pyproject.toml index 35a53f9..bb49451 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,6 @@ [build-system] -requires = ["setuptools>=61.0"] +# setuptools>=77 is required for the PEP 639 `license` / `license-files` fields. +requires = ["setuptools>=77.0"] build-backend = "setuptools.build_meta" [project] @@ -7,14 +8,38 @@ name = "deepspotm" version = "1.0.0" description = "Predicts spatial gene expression from histology images using pathology foundation models" readme = "README.md" -# Code is released for non-commercial use only; see LICENSE (PolyForm -# Noncommercial 1.0.0). The model WEIGHTS carry a separate CC-BY-NC-SA-4.0 -# license — see WEIGHTS_LICENSE. -license = { text = "PolyForm-Noncommercial-1.0.0" } +# Code is released for non-commercial use only (PolyForm Noncommercial 1.0.0). +# The model WEIGHTS carry a separate CC-BY-NC-SA-4.0 license, see WEIGHTS_LICENSE.md. +license = "PolyForm-Noncommercial-1.0.0" +license-files = ["LICENSE", "WEIGHTS_LICENSE.md", "THIRD_PARTY_LICENSES.md"] authors = [ { name = "Kalin Nonchev", email = "kalin.nonchev@inf.ethz.ch" } ] -requires-python = ">=3.9" +keywords = [ + "spatial-transcriptomics", + "computational-pathology", + "histology", + "gene-expression", + "foundation-model", + "whole-slide-imaging", +] +# torch, transformers, peft and lightning all require >=3.10 in their current +# releases, so a 3.9 install cannot resolve a working dependency set. +requires-python = ">=3.10" + +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Scientific/Engineering :: Bio-Informatics", + "Topic :: Scientific/Engineering :: Image Recognition", +] dependencies = [ "torch>=2.0", @@ -29,12 +54,33 @@ dependencies = [ "pillow>=9.0", ] +[project.optional-dependencies] +# Extras for examples/predict_wsi.py, which reads slides and writes AnnData. +wsi = [ + "pyvips>=2.2", + "anndata>=0.10", +] +test = [ + "pytest>=7.0", +] +dev = [ + "deepspotm[test,wsi]", + "build>=1.0", + "ruff>=0.6", + "twine>=5.0", +] + [project.urls] Homepage = "https://github.com/ratschlab/DeepSpotM" Issues = "https://github.com/ratschlab/DeepSpotM/issues" +Paper = "https://www.medrxiv.org/content/10.64898/2026.06.19.26356060v1" +Weights = "https://huggingface.co/ratschlab/DeepSpotM" [tool.setuptools.packages.find] where = ["src"] [tool.setuptools.package-data] deepspotm = ["assets/*.csv", "assets/*.json"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/deepspotm/config.py b/src/deepspotm/config.py index 3f0eb15..7ad7d3f 100644 --- a/src/deepspotm/config.py +++ b/src/deepspotm/config.py @@ -1,15 +1,17 @@ -from pathlib import Path import importlib.resources - +from pathlib import Path class Config: """Configuration class for DeepSpotM model.""" - # Gene vocabulary + # Gene vocabulary. `files()` replaces `importlib.resources.path()`, which is + # deprecated since Python 3.11 and hands back a context-managed path that is + # released on exit, so the value could outlive the guarantee it was valid. try: - with importlib.resources.path("deepspotm.assets", "tokens.csv") as p: - ALPHABET_PATH = Path(p) + ALPHABET_PATH = Path( + str(importlib.resources.files("deepspotm.assets") / "tokens.csv") + ) except Exception: ALPHABET_PATH = Path(__file__).resolve().parent / "assets" / "tokens.csv" diff --git a/tests/test_checkpoint.py b/tests/test_checkpoint.py new file mode 100644 index 0000000..ea47bb3 --- /dev/null +++ b/tests/test_checkpoint.py @@ -0,0 +1,68 @@ +"""Tests for the checkpoint-loading guards. + +`check_state_dict_load` exists to stop a checkpoint/architecture mismatch from silently +producing predictions out of uninitialised weights. That guard is worth a test, because +a regression in it fails quietly and looks like a bad model rather than a bad load. +""" + +import pytest + +from deepspotm.checkpoint import ( + VOCAB_SPECIFIC_PREFIXES, + check_state_dict_load, + filter_state_dict, +) + + +def test_vocab_specific_prefixes_shape(): + """The prefix list is a non-empty tuple of strings.""" + assert isinstance(VOCAB_SPECIFIC_PREFIXES, tuple) + assert VOCAB_SPECIFIC_PREFIXES + assert all(isinstance(p, str) for p in VOCAB_SPECIFIC_PREFIXES) + + +def test_filter_state_dict_drops_matching_prefixes(): + """Keys under a dropped prefix are removed and everything else is kept.""" + state = { + "gene_decoder.gene_embeddings.weight": 1, + "gene_decoder._router_bio_emb": 2, + "image_encoder.blocks.0.weight": 3, + } + filtered = filter_state_dict(state, VOCAB_SPECIFIC_PREFIXES) + assert filtered == {"image_encoder.blocks.0.weight": 3} + + +def test_filter_state_dict_without_prefixes_is_a_copy(): + """Filtering on nothing returns an equal but distinct dict.""" + state = {"a": 1} + filtered = filter_state_dict(state, ()) + assert filtered == state + assert filtered is not state + + +def test_check_state_dict_load_accepts_clean_load(): + """No drift is not an error.""" + check_state_dict_load([], []) + + +@pytest.mark.parametrize( + ("missing", "unexpected"), + [ + (["image_encoder.blocks.0.weight"], []), + ([], ["image_encoder.blocks.0.weight"]), + ], +) +def test_check_state_dict_load_rejects_unexplained_drift(missing, unexpected): + """Drift outside the allow lists raises rather than loading silently.""" + with pytest.raises(RuntimeError, match="unaccounted-for key drift"): + check_state_dict_load(missing, unexpected) + + +def test_check_state_dict_load_honours_allow_lists(): + """Drift that the caller declared expected is permitted.""" + check_state_dict_load( + ["gene_decoder.gene_embeddings.weight"], + ["gene_decoder._router_bio_emb"], + allow_missing_prefixes=VOCAB_SPECIFIC_PREFIXES, + allow_unexpected_prefixes=VOCAB_SPECIFIC_PREFIXES, + ) diff --git a/tests/test_decoder.py b/tests/test_decoder.py new file mode 100644 index 0000000..ee5f916 --- /dev/null +++ b/tests/test_decoder.py @@ -0,0 +1,93 @@ +"""Contract tests for the cross-attention gene decoder. + +These build a tiny randomly-initialised decoder instead of downloading the gated +checkpoint, so CI can assert the output contract that downstream integrations rely on: +`forward` returns (expression, gene_features, attn_weights), expression is +(batch, n_requested_genes), and `gene_indices` both subsets and orders the columns. +TIAToolbox unpacks exactly this three-tuple. +""" + +import pytest +import torch + +from deepspotm.modules import ( + MODALITY_BY_SOURCE, + MODALITY_VOCABULARY, + CrossAttentionGeneDecoder, +) + +BATCH = 2 +N_PATCHES = 5 +PATCH_DIM = 16 +N_GENES = 12 +GENE_EMBED_DIM = 8 + + +@pytest.fixture +def decoder(): + """A small decoder with random gene embeddings.""" + torch.manual_seed(0) + return CrossAttentionGeneDecoder( + n_genes=N_GENES, + patch_dim=PATCH_DIM, + gene_embed_dim=GENE_EMBED_DIM, + num_heads=2, + num_layers=1, + ).eval() + + +@pytest.fixture +def patch_tokens(): + """A batch of patch tokens as produced by the image encoder.""" + torch.manual_seed(1) + return torch.randn(BATCH, N_PATCHES, PATCH_DIM) + + +def test_forward_returns_the_documented_triple(decoder, patch_tokens): + """forward yields (expression, gene_features, attn_weights) with attn None by default.""" + with torch.no_grad(): + expression, gene_features, attn = decoder(patch_tokens) + + assert expression.shape == (BATCH, N_GENES) + assert gene_features.shape == (BATCH, GENE_EMBED_DIM) + assert attn is None + assert torch.isfinite(expression).all() + + +def test_gene_indices_subset_the_output(decoder, patch_tokens): + """Requesting a subset computes only those genes, in the requested order.""" + wanted = torch.tensor([7, 1, 4]) + with torch.no_grad(): + subset, _, _ = decoder(patch_tokens, gene_indices=wanted) + full, _, _ = decoder(patch_tokens) + + assert subset.shape == (BATCH, len(wanted)) + torch.testing.assert_close(subset, full[:, wanted], rtol=1e-4, atol=1e-5) + + +def test_gene_indices_order_is_respected(decoder, patch_tokens): + """Reversing the requested indices reverses the output columns.""" + wanted = torch.tensor([2, 9]) + with torch.no_grad(): + forward_order, _, _ = decoder(patch_tokens, gene_indices=wanted) + reverse_order, _, _ = decoder(patch_tokens, gene_indices=wanted.flip(0)) + + torch.testing.assert_close( + forward_order, reverse_order.flip(1), rtol=1e-4, atol=1e-5 + ) + + +def test_need_weights_returns_attention(decoder, patch_tokens): + """Interpretability path returns per-layer attention instead of None.""" + with torch.no_grad(): + _, _, attn = decoder(patch_tokens, need_weights=True) + + assert attn is not None + assert len(attn) >= 1 + + +def test_modality_registry_is_consistent(): + """Every registered source maps to a modality in the canonical vocabulary.""" + assert set(MODALITY_BY_SOURCE.values()) <= set(MODALITY_VOCABULARY) + # The ordering is load-bearing: the index is what the one-hot encoding uses. + assert MODALITY_VOCABULARY[0] == "dna" diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..5230ba2 --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,63 @@ +"""Packaging and asset tests. + +The model weights are gated, so CI can never load a real checkpoint. What CI can +guarantee is that the package installs, that its public API is importable, and that +the data files the model depends on are actually shipped in the wheel. Those are the +failures that silently reach users. +""" + +import csv +import importlib.metadata +import importlib.resources + +import pytest + +import deepspotm + +# The released panel size. The model reports len(gene_names) == 19338, which is +# derived from this file, so a change here is a change to the model's output width. +EXPECTED_GENES = 19338 + + +def test_version_is_installed(): + """The distribution is installed and exposes a version.""" + assert importlib.metadata.version("deepspotm") + + +@pytest.mark.parametrize("name", deepspotm.__all__) +def test_public_api_is_importable(name): + """Every name promised by __all__ actually exists on the package.""" + assert hasattr(deepspotm, name), ( + f"deepspotm.__all__ advertises missing name {name!r}" + ) + + +@pytest.mark.parametrize( + "asset", + ["tokens.csv", "ensp_to_gene.csv", "midnight_config.json"], +) +def test_asset_is_packaged(asset): + """Data files declared in package-data are present in the installed package.""" + resource = importlib.resources.files("deepspotm.assets") / asset + assert resource.is_file(), f"{asset} is missing from the installed package" + assert resource.read_bytes(), f"{asset} is empty" + + +def test_token_vocabulary_matches_model_panel(): + """tokens.csv carries the gene panel the model reports.""" + text = (importlib.resources.files("deepspotm.assets") / "tokens.csv").read_text() + rows = list(csv.DictReader(text.splitlines())) + + genes = [r for r in rows if r["token_type"] == "gene"] + assert len(genes) == EXPECTED_GENES + + # Token ids must be a dense 0..n-1 range; the decoder indexes into them directly. + ids = sorted(int(r["token_id"]) for r in rows) + assert ids == list(range(len(rows))) + + +def test_config_alphabet_path_resolves(): + """Config resolves the packaged vocabulary rather than a source-tree path.""" + from deepspotm.config import config + + assert config.ALPHABET_PATH.is_file() diff --git a/tests/test_structure_expression.py b/tests/test_structure_expression.py new file mode 100644 index 0000000..64dea0d --- /dev/null +++ b/tests/test_structure_expression.py @@ -0,0 +1,54 @@ +"""Tests for the gene-name to fixed-length-tensor mapping. + +`StructureExpression` reads the packaged vocabulary, so it doubles as a check that the +shipped tokens.csv is usable from an installed wheel rather than only from a source tree. +""" + +import torch + +from deepspotm.modules import StructureExpression + + +def test_vocabulary_loads_from_packaged_assets(): + """The default constructor resolves the packaged gene vocabulary.""" + structurer = StructureExpression() + assert len(structurer.gene_names_ordered) == 19338 + # token_type filtering must exclude the disease/tissue/special tokens. + assert "" not in set(structurer.gene_names_ordered) + + +def test_structure_expression_places_values_by_gene_name(): + """Measured genes land at their vocabulary position; the rest are zero.""" + structurer = StructureExpression() + genes = list(structurer.gene_names_ordered) + target, other = genes[3], genes[100] + + values, mask = structurer.structure_expression({target: 2.5, other: 1.0}) + + assert values.shape == (len(genes),) + assert mask.shape == (len(genes),) + assert values[3].item() == 2.5 + assert values[100].item() == 1.0 + assert mask[3] and mask[100] + assert mask.sum().item() == 2 + assert values.sum().item() == 3.5 + + +def test_negative_values_are_clamped(): + """Expression is non-negative; negatives are clamped rather than passed through.""" + structurer = StructureExpression() + target = structurer.gene_names_ordered[0] + + values, _ = structurer.structure_expression({target: -5.0}) + + assert values.min().item() == 0.0 + + +def test_unknown_gene_names_are_ignored(): + """A symbol outside the vocabulary does not shift or corrupt the output.""" + structurer = StructureExpression() + + values, mask = structurer.structure_expression({"NOT_A_REAL_GENE": 9.0}) + + assert not mask.any() + assert torch.count_nonzero(values).item() == 0 diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..e980267 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,95 @@ +"""Tests for the image preprocessing helpers. + +`get_eval_transforms` is the boundary every downstream integration crosses: TIAToolbox +hands it channel-last uint8 tiles, the WSI example hands it PIL images, and the model's +own transform hands it tensors. Each of those has to come out the same shape. +""" + +import numpy as np +import pytest +import torch +from PIL import Image +from torchvision import transforms + +from deepspotm.utils import get_eval_transforms, get_normalize_params + +MEAN = (0.485, 0.456, 0.406) +STD = (0.229, 0.224, 0.225) + + +@pytest.fixture +def transform(): + """The evaluation transform used at 224 px with a center crop.""" + return get_eval_transforms(MEAN, STD, target_img_size=224, center_crop=True) + + +@pytest.mark.parametrize( + "make_input", + [ + pytest.param( + lambda: np.random.randint(0, 256, (256, 300, 3), dtype=np.uint8), + id="numpy-uint8", + ), + pytest.param( + lambda: np.random.rand(256, 300, 3).astype(np.float32), id="numpy-float" + ), + pytest.param( + lambda: Image.fromarray(np.zeros((256, 300, 3), np.uint8)), id="pil" + ), + pytest.param(lambda: torch.rand(3, 256, 300), id="tensor-chw"), + ], +) +def test_accepts_every_input_type(transform, make_input): + """Numpy, PIL and tensor inputs all normalize to the same (3, 224, 224) tensor.""" + out = transform(make_input()) + assert isinstance(out, torch.Tensor) + assert out.shape == (3, 224, 224) + assert out.dtype == torch.float32 + + +def test_rejects_unsupported_input(transform): + """An unsupported type fails loudly instead of silently producing garbage.""" + with pytest.raises(TypeError): + transform("not an image") + + +def test_normalization_is_applied(): + """With mean/std given, the output is standardized; without, it stays in [0, 1].""" + tile = np.full((224, 224, 3), 128, dtype=np.uint8) + + normalized = get_eval_transforms(MEAN, STD, target_img_size=224)(tile) + raw = get_eval_transforms(None, None, target_img_size=224)(tile) + + assert 0.0 <= raw.min() and raw.max() <= 1.0 + torch.testing.assert_close(raw.mean().item(), 128 / 255, rtol=1e-3, atol=1e-3) + assert not torch.allclose(normalized, raw) + + +def test_center_crop_requires_a_size(): + """center_crop without target_img_size is a configuration error.""" + with pytest.raises(AssertionError): + get_eval_transforms(MEAN, STD, target_img_size=-1, center_crop=True) + + +def test_get_normalize_params_from_compose(): + """Normalize parameters are recovered from a torchvision pipeline.""" + compose = transforms.Compose( + [transforms.ToTensor(), transforms.Normalize(MEAN, STD)] + ) + mean, std = get_normalize_params(compose) + assert tuple(mean) == MEAN + assert tuple(std) == STD + + +def test_get_normalize_params_without_normalize(): + """A pipeline with no Normalize step reports no parameters.""" + compose = transforms.Compose([transforms.ToTensor()]) + assert get_normalize_params(compose) == (None, None) + + +def test_get_normalize_params_from_processor_dict(): + """HuggingFace-style processor dicts are supported.""" + processor = {"image_mean": list(MEAN), "image_std": list(STD)} + mean, std = get_normalize_params(processor) + assert tuple(mean) == MEAN + assert tuple(std) == STD