Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 3 additions & 46 deletions .github/workflows/pr-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
2 changes: 1 addition & 1 deletion slide2vec/hs2p
33 changes: 0 additions & 33 deletions test/development-README.md

This file was deleted.

26 changes: 0 additions & 26 deletions test/input/config.yaml

This file was deleted.

2 changes: 0 additions & 2 deletions test/input/test.csv

This file was deleted.

File renamed without changes
File renamed without changes.
File renamed without changes.
File renamed without changes
File renamed without changes.
File renamed without changes.
167 changes: 167 additions & 0 deletions tests/test_output_consistency.py
Original file line number Diff line number Diff line change
@@ -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}")
File renamed without changes.
Loading