From a5312c91935f620db8290a96b977c477bc41352d Mon Sep 17 00:00:00 2001 From: clemsgrs Date: Wed, 18 Feb 2026 13:06:50 +0000 Subject: [PATCH 1/2] sync hs2p --- slide2vec/hs2p | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slide2vec/hs2p b/slide2vec/hs2p index 3dac2e9..591ff92 160000 --- a/slide2vec/hs2p +++ b/slide2vec/hs2p @@ -1 +1 @@ -Subproject commit 3dac2e908c64be291bebd386e647f0b258c47c72 +Subproject commit 591ff92478a6e051a10b414381d0cc3b5b575341 From a70b24b8c07572dfa1bca53efabb5e9fb5fbb8a9 Mon Sep 17 00:00:00 2001 From: clemsgrs Date: Wed, 18 Feb 2026 14:58:19 +0000 Subject: [PATCH 2/2] improve testing --- .github/workflows/pr-test.yaml | 49 +----- test/development-README.md | 33 ---- test/input/config.yaml | 26 --- test/input/test.csv | 2 - {test => tests/fixtures}/gt/mask-visu.jpg | Bin {test => tests/fixtures}/gt/test-wsi.npy | Bin {test => tests/fixtures}/gt/test-wsi.pt | Bin {test => tests/fixtures}/gt/tiling-visu.jpg | Bin {test => tests/fixtures}/input/test-mask.tif | Bin {test => tests/fixtures}/input/test-wsi.tif | Bin tests/test_output_consistency.py | 167 +++++++++++++++++++ {test => tests}/test_regression_bugfixes.py | 0 12 files changed, 170 insertions(+), 107 deletions(-) delete mode 100644 test/development-README.md delete mode 100644 test/input/config.yaml delete mode 100644 test/input/test.csv rename {test => tests/fixtures}/gt/mask-visu.jpg (100%) rename {test => tests/fixtures}/gt/test-wsi.npy (100%) rename {test => tests/fixtures}/gt/test-wsi.pt (100%) rename {test => tests/fixtures}/gt/tiling-visu.jpg (100%) rename {test => tests/fixtures}/input/test-mask.tif (100%) rename {test => tests/fixtures}/input/test-wsi.tif (100%) create mode 100644 tests/test_output_consistency.py rename {test => tests}/test_regression_bugfixes.py (100%) diff --git a/.github/workflows/pr-test.yaml b/.github/workflows/pr-test.yaml index 75c35da..748abf3 100644 --- a/.github/workflows/pr-test.yaml +++ b/.github/workflows/pr-test.yaml @@ -47,13 +47,6 @@ jobs: submodules: recursive fetch-depth: 0 - - name: Verify required folders exist - run: | - set -euo pipefail - test -d test/input - test -d test/gt - mkdir -p test/output # ensure host-mapped output exists - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -86,48 +79,12 @@ jobs: set -euo pipefail test -n "${HF_TOKEN:-}" || { echo "HF_TOKEN is required but not set"; exit 1; } - - name: Generate outputs in container + - name: Run test suite in container run: | set -euo pipefail docker run --rm \ -e HF_TOKEN="$HF_TOKEN" \ - -v "$GITHUB_WORKSPACE/test/input:/input" \ - -v "$GITHUB_WORKSPACE/test/output:/output" \ - slide2vec-ci:${{ github.sha }} \ - python slide2vec/main.py \ - --config-file /input/config.yaml \ - --skip-datetime \ - --run-on-cpu - - - name: Verify output consistency (inside container) - run: | - set -euo pipefail - docker run --rm \ - -v "$GITHUB_WORKSPACE/test/gt:/gt" \ - -v "$GITHUB_WORKSPACE/test/output:/output" \ + -v "$GITHUB_WORKSPACE:/opt/app" \ slide2vec-ci:${{ github.sha }} \ - bash -lc "python - <<'PY' - import numpy as np, torch - from numpy.testing import assert_array_equal - - # coordinates must match exactly (deterministic tiling) - gt_coordinates = np.load('/gt/test-wsi.npy') - coordinates = np.load('/output/coordinates/test-wsi.npy') - assert_array_equal(coordinates, gt_coordinates), f'Coordinates mismatch: {coordinates} vs {gt_coordinates}' - - # embeddings: allow tiny numeric drift - gt = torch.load('/gt/test-wsi.pt', map_location='cpu') - emb = torch.load('/output/features/test-wsi.pt', map_location='cpu') - assert emb.shape == gt.shape, f'Shape mismatch: {emb.shape} vs {gt.shape}' + bash -lc "python -m pip install --no-cache-dir pytest pytest-cov && python -m pytest -q tests" - cos = torch.nn.functional.cosine_similarity(emb, gt, dim=-1) - mean_cos = float(cos.mean()) - atol, rtol = 1e-2, 1e-3 - if not torch.allclose(emb, gt, atol=atol, rtol=rtol): - if mean_cos < 0.99: - raise AssertionError(f'Embedding mismatch: mean cosine similarity={mean_cos:.4f} (atol={atol}, rtol={rtol})') - else: - print(f'WARNING: embeddings not allclose, but mean cosine similarity={mean_cos:.4f} (atol={atol}, rtol={rtol})') - else: - print(f'OK: embeddings within tolerance; mean cosine similarity={mean_cos:.4f}') - PY" diff --git a/test/development-README.md b/test/development-README.md deleted file mode 100644 index f6a4e26..0000000 --- a/test/development-README.md +++ /dev/null @@ -1,33 +0,0 @@ -# Steps to set up testing environment -Set up conda environment: - -``` -conda create --name=slide2vec python=3.12 -``` - -Activate environment: - -``` -conda activate slide2vec -``` - -Install module and additional development dependencies: - -``` -pip install -e . -pip install -r requirements_dev.txt -``` - -Perform tests: - -``` -./do_build.sh -``` - -# Programming conventions -AutoPEP8 for formatting (this can be done automatically on save, see e.g. https://code.visualstudio.com/docs/python/editing) - -# Push release to PyPI -1. Increase version in setup.py, and set below -2. Build: `python -m build` -3. Distribute package to PyPI: `python -m twine upload dist/*0.0.1*` diff --git a/test/input/config.yaml b/test/input/config.yaml deleted file mode 100644 index 5c07deb..0000000 --- a/test/input/config.yaml +++ /dev/null @@ -1,26 +0,0 @@ -csv: "/input/test.csv" - -output_dir: "/output" -visualize: false - -tiling: - params: - spacing: 0.5 # spacing at which to tile the slide, in microns per pixel - tolerance: 0.07 # tolerance for matching the spacing (float between 0 and 1, deciding how much the spacing can deviate from the one specified in the slide metadata) - tile_size: 224 # size of the tiles to extract, in pixels - min_tissue_percentage: 0.1 # threshold used to filter out tiles that have less tissue than this value (percentage) - filter_params: - ref_tile_size: 224 - -model: - level: "slide" - name: "prism" - batch_size: 8 - -speed: - fp16: true - num_workers: 4 - num_workers_embedding: 4 - -wandb: - enable: false \ No newline at end of file diff --git a/test/input/test.csv b/test/input/test.csv deleted file mode 100644 index 80c7ed2..0000000 --- a/test/input/test.csv +++ /dev/null @@ -1,2 +0,0 @@ -wsi_path,mask_path -/input/test-wsi.tif,/input/test-mask.tif \ No newline at end of file diff --git a/test/gt/mask-visu.jpg b/tests/fixtures/gt/mask-visu.jpg similarity index 100% rename from test/gt/mask-visu.jpg rename to tests/fixtures/gt/mask-visu.jpg diff --git a/test/gt/test-wsi.npy b/tests/fixtures/gt/test-wsi.npy similarity index 100% rename from test/gt/test-wsi.npy rename to tests/fixtures/gt/test-wsi.npy diff --git a/test/gt/test-wsi.pt b/tests/fixtures/gt/test-wsi.pt similarity index 100% rename from test/gt/test-wsi.pt rename to tests/fixtures/gt/test-wsi.pt diff --git a/test/gt/tiling-visu.jpg b/tests/fixtures/gt/tiling-visu.jpg similarity index 100% rename from test/gt/tiling-visu.jpg rename to tests/fixtures/gt/tiling-visu.jpg diff --git a/test/input/test-mask.tif b/tests/fixtures/input/test-mask.tif similarity index 100% rename from test/input/test-mask.tif rename to tests/fixtures/input/test-mask.tif diff --git a/test/input/test-wsi.tif b/tests/fixtures/input/test-wsi.tif similarity index 100% rename from test/input/test-wsi.tif rename to tests/fixtures/input/test-wsi.tif diff --git a/tests/test_output_consistency.py b/tests/test_output_consistency.py new file mode 100644 index 0000000..c473969 --- /dev/null +++ b/tests/test_output_consistency.py @@ -0,0 +1,167 @@ +import os +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest +import torch +from omegaconf import OmegaConf + +# --------------------------------------------------------------------------- +# Hardcoded pipeline parameters +# --------------------------------------------------------------------------- + +# -- tiling.params -- +TILING_PARAMS = dict( + spacing=0.5, + tolerance=0.07, # override (default: 0.05) + tile_size=224, # override (default: 256) + overlap=0.0, + min_tissue_percentage=0.1, # override (default: 0.01) + drop_holes=False, + use_padding=True, +) + +# -- tiling.seg_params -- +TILING_SEG_PARAMS = dict( + downsample=64, # override (default: 16) + sthresh=8, + sthresh_up=255, + mthresh=7, + close=4, + use_otsu=False, + use_hsv=True, +) + +# -- tiling.filter_params -- +TILING_FILTER_PARAMS = dict( + ref_tile_size=224, # override (default: 16) + a_t=4, + a_h=2, + max_n_holes=8, + filter_white=False, + filter_black=False, + white_threshold=220, + black_threshold=25, + fraction_threshold=0.9, +) + +# -- tiling.visu_params -- +TILING_VISU_PARAMS = dict(downsample=32) + +# -- model -- +MODEL_PARAMS = dict( + level="slide", # override (default: "tile") + name="prism", # override (default: null) + mode="cls", + arch=None, + pretrained_weights=None, + batch_size=8, # override (default: 256) + tile_size=224, # resolved from ${tiling.params.tile_size} + restrict_to_tissue=False, + patch_size=256, + token_size=16, + save_tile_embeddings=False, + save_latents=False, +) + +# -- speed -- +SPEED_PARAMS = dict( + fp16=True, # override (default: false) + num_workers=4, # override (default: 8) + num_workers_embedding=4, # override (default: 8) +) + +# --------------------------------------------------------------------------- +# Paths relative to this test file +# --------------------------------------------------------------------------- +TEST_DIR = Path(__file__).parent +INPUT_DIR = TEST_DIR / "fixtures" / "input" +GT_DIR = TEST_DIR / "fixtures" / "gt" +REPO_ROOT = TEST_DIR.parent + + +@pytest.fixture(scope="module") +def wsi_path() -> Path: + p = INPUT_DIR / "test-wsi.tif" + if not p.is_file(): + pytest.skip(f"Test fixture missing: {p}") + return p + + +@pytest.fixture(scope="module") +def mask_path() -> Path: + p = INPUT_DIR / "test-mask.tif" + if not p.is_file(): + pytest.skip(f"Test fixture missing: {p}") + return p + + +@pytest.mark.skipif( + not os.environ.get("HF_TOKEN"), + reason="HF_TOKEN required for model weight download", +) +def test_output_consistency(wsi_path, mask_path, tmp_path): + """Running the full pipeline with hardcoded params produces coordinates and + embeddings that match the ground truth fixtures in test/gt/.""" + + # 1. Build a temporary CSV with resolved absolute paths + tmp_csv = tmp_path / "test.csv" + tmp_csv.write_text(f"wsi_path,mask_path\n{wsi_path},{mask_path}\n") + + # 2. Build config from hardcoded constants (no dependency on test/input/config.yaml) + cfg = OmegaConf.create({ + "csv": str(tmp_csv), + "output_dir": str(tmp_path), + "resume": False, + "resume_dirname": None, + "visualize": False, # override (default: true) + "seed": 0, + "tiling": { + "read_coordinates_from": None, + "backend": "asap", + "params": TILING_PARAMS, + "seg_params": TILING_SEG_PARAMS, + "filter_params": TILING_FILTER_PARAMS, + "visu_params": TILING_VISU_PARAMS, + }, + "model": MODEL_PARAMS, + "speed": SPEED_PARAMS, + "wandb": {"enable": False}, + }) + cfg_path = tmp_path / "config.yaml" + OmegaConf.save(cfg, cfg_path) + + # 3. Run the pipeline + subprocess.run( + [ + sys.executable, "slide2vec/main.py", + "--config-file", str(cfg_path), + "--skip-datetime", + "--run-on-cpu", + ], + cwd=REPO_ROOT, + check=True, + ) + + # 4. Assert coordinates match exactly (tiling is deterministic) + gt_coords = np.load(GT_DIR / "test-wsi.npy", allow_pickle=False) + coords = np.load(tmp_path / "coordinates" / "test-wsi.npy", allow_pickle=False) + np.testing.assert_array_equal(coords, gt_coords) + + # 5. Assert embeddings are within tolerance + gt_emb = torch.load(GT_DIR / "test-wsi.pt", map_location="cpu") + emb = torch.load(tmp_path / "features" / "test-wsi.pt", map_location="cpu") + assert emb.shape == gt_emb.shape, f"Shape mismatch: {emb.shape} vs {gt_emb.shape}" + + cos = torch.nn.functional.cosine_similarity(emb, gt_emb, dim=-1) + mean_cos = float(cos.mean()) + atol, rtol = 1e-2, 1e-3 + if not torch.allclose(emb, gt_emb, atol=atol, rtol=rtol): + assert mean_cos >= 0.99, ( + f"Embedding mismatch: mean cosine similarity={mean_cos:.4f} " + f"(atol={atol}, rtol={rtol})" + ) + else: + print(f"OK: embeddings within tolerance; mean cosine similarity={mean_cos:.4f}") diff --git a/test/test_regression_bugfixes.py b/tests/test_regression_bugfixes.py similarity index 100% rename from test/test_regression_bugfixes.py rename to tests/test_regression_bugfixes.py