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
36 changes: 9 additions & 27 deletions .github/workflows/pr-test.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Test WSI to coordinates consistency
name: Test revamped suite

on:
pull_request:
Expand All @@ -23,9 +23,10 @@ jobs:
- 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
test -d test
test -f test/fixtures/input/test-wsi.tif
test -f test/fixtures/input/test-mask.tif
test -f test/fixtures/gt/test-wsi.npy

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
Expand Down Expand Up @@ -53,30 +54,11 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Generate outputs in container
- name: Run revamped tests in container
run: |
set -euo pipefail
docker run --rm \
-v "$GITHUB_WORKSPACE/test/input:/input" \
-v "$GITHUB_WORKSPACE/test/output:/output" \
-v "$GITHUB_WORKSPACE:/workspace" \
-w /workspace \
hs2p:${{ github.sha }} \
python hs2p/tiling.py \
--config-file /input/config.yaml \
--skip-datetime

- 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" \
hs2p:${{ github.sha }} \
bash -lc "python - <<'PY'
import numpy as np
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'
PY"
bash -lc "python -m pip install --no-cache-dir pytest pytest-cov && MPLCONFIGDIR=/tmp/mpl python -m pytest -q test"
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[tool.pytest.ini_options]
addopts = "--cov=hs2p"
testpaths = [
"tests",
"test",
]

[tool.mypy]
Expand Down
38 changes: 38 additions & 0 deletions test/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from __future__ import annotations

import sys
from pathlib import Path

import numpy as np
import pytest

import hs2p.wsi.wsi as wsimod

HELPERS_DIR = Path(__file__).resolve().parent / "helpers"
if str(HELPERS_DIR) not in sys.path:
sys.path.insert(0, str(HELPERS_DIR))

from fake_wsi_backend import FakeWSDFactory, make_mask_spec, make_slide_spec


@pytest.fixture
def fake_backend(monkeypatch):
def _apply(mask_l0: np.ndarray):
factory = FakeWSDFactory(
slide_spec=make_slide_spec(),
mask_spec=make_mask_spec(mask_l0),
)
monkeypatch.setattr(wsimod.wsd, "WholeSlideImage", factory)
return factory

return _apply


@pytest.fixture
def real_fixture_paths() -> tuple[Path, Path]:
base = Path(__file__).resolve().parent / "fixtures" / "input"
wsi_path = base / "test-wsi.tif"
mask_path = base / "test-mask.tif"
if not wsi_path.is_file() or not mask_path.is_file():
pytest.skip("Real fixture TIFF files are not present")
return wsi_path, mask_path
File renamed without changes
File renamed without changes.
File renamed without changes
File renamed without changes.
File renamed without changes.
73 changes: 73 additions & 0 deletions test/helpers/fake_wsi_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path

import numpy as np


@dataclass
class PyramidSpec:
spacings: list[float]
levels: list[np.ndarray]


class FakePyramidWSI:
def __init__(self, spec: PyramidSpec):
self.spacings = spec.spacings
self._levels = spec.levels
self.shapes = tuple((arr.shape[1], arr.shape[0]) for arr in self._levels)
self.downsamplings = tuple(self.shapes[0][0] / shape[0] for shape in self.shapes)

def _best_level(self, spacing: float) -> int:
return int(np.argmin([abs(s - spacing) for s in self.spacings]))

def get_slide(self, spacing: float) -> np.ndarray:
return self._levels[self._best_level(spacing)]

def get_patch(
self,
x: int,
y: int,
width: int,
height: int,
spacing: float,
center: bool = False,
) -> np.ndarray:
del center
level = self._best_level(spacing)
downsample = self.downsamplings[level]
arr = self._levels[level]

x_level = int(round(x / downsample))
y_level = int(round(y / downsample))

patch = np.zeros((height, width, arr.shape[2]), dtype=arr.dtype)
src = arr[y_level : y_level + height, x_level : x_level + width, :]
patch[: src.shape[0], : src.shape[1], :] = src
return patch


class FakeWSDFactory:
def __init__(self, slide_spec: PyramidSpec, mask_spec: PyramidSpec):
self.slide_spec = slide_spec
self.mask_spec = mask_spec

def __call__(self, path: Path, backend: str = "asap") -> FakePyramidWSI:
del backend
name = str(path).lower()
if "mask" in name:
return FakePyramidWSI(self.mask_spec)
return FakePyramidWSI(self.slide_spec)


def make_slide_spec() -> PyramidSpec:
slide_l0 = np.ones((32, 32, 3), dtype=np.uint8) * 180
slide_l1 = slide_l0[::2, ::2, :]
return PyramidSpec(spacings=[1.0, 2.0], levels=[slide_l0, slide_l1])


def make_mask_spec(mask_l0: np.ndarray) -> PyramidSpec:
mask_l0 = mask_l0.astype(np.uint8)
mask_l1 = mask_l0[::2, ::2, :]
return PyramidSpec(spacings=[2.0, 4.0], levels=[mask_l0, mask_l1])
76 changes: 76 additions & 0 deletions test/helpers/params.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from __future__ import annotations

from types import SimpleNamespace


def make_segment_params(
downsample: int = 2,
sthresh: int = 8,
sthresh_up: int = 255,
mthresh: int = 3,
close: int = 0,
use_otsu: bool = False,
use_hsv: bool = False,
):
return SimpleNamespace(
downsample=downsample,
sthresh=sthresh,
sthresh_up=sthresh_up,
mthresh=mthresh,
close=close,
use_otsu=use_otsu,
use_hsv=use_hsv,
)


def make_filter_params(
ref_tile_size: int = 4,
a_t: int = 0,
a_h: int = 0,
max_n_holes: int = 8,
filter_white: bool = False,
filter_black: bool = False,
white_threshold: int = 220,
black_threshold: int = 25,
fraction_threshold: float = 0.9,
):
return SimpleNamespace(
ref_tile_size=ref_tile_size,
a_t=a_t,
a_h=a_h,
max_n_holes=max_n_holes,
filter_white=filter_white,
filter_black=filter_black,
white_threshold=white_threshold,
black_threshold=black_threshold,
fraction_threshold=fraction_threshold,
)


def make_tiling_params(
spacing: float = 1.0,
tolerance: float = 0.01,
tile_size: int = 8,
overlap: float = 0.0,
min_tissue_percentage: float = 0.0,
drop_holes: bool = False,
use_padding: bool = False,
):
return SimpleNamespace(
spacing=spacing,
tolerance=tolerance,
tile_size=tile_size,
overlap=overlap,
min_tissue_percentage=min_tissue_percentage,
drop_holes=drop_holes,
use_padding=use_padding,
)


def make_sampling_params(pixel_mapping: dict[str, int], tissue_percentage: dict[str, float | None]):
color_mapping = {k: None for k in pixel_mapping}
return SimpleNamespace(
pixel_mapping=pixel_mapping,
color_mapping=color_mapping,
tissue_percentage=tissue_percentage,
)
24 changes: 0 additions & 24 deletions test/input/config.yaml

This file was deleted.

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

This file was deleted.

79 changes: 79 additions & 0 deletions test/test_legacy_coordinates_regression.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from __future__ import annotations

from pathlib import Path

import numpy as np
import pytest

import hs2p.wsi as wsi_api
from params import make_filter_params, make_sampling_params, make_segment_params, make_tiling_params


def _require_asap_backend(wsi_path: Path) -> str:
wsd = pytest.importorskip("wholeslidedata")
try:
wsd.WholeSlideImage(wsi_path, backend="asap")
return "asap"
except Exception:
pytest.skip("ASAP backend is unavailable; golden coordinate regression requires ASAP")


def test_generated_coordinates_match_legacy_golden(real_fixture_paths, tmp_path: Path):
wsi_path, mask_path = real_fixture_paths
gt_path = wsi_path.parent.parent / "gt" / "test-wsi.npy"
assert gt_path.is_file(), f"Missing golden coordinates: {gt_path}"

backend = _require_asap_backend(wsi_path)
tissue_pct = 0.1

coordinates, contour_indices, tile_level, resize_factor, tile_size_lv0 = wsi_api.extract_coordinates(
wsi_path=wsi_path,
mask_path=mask_path,
backend=backend,
segment_params=make_segment_params(
downsample=64,
sthresh=8,
sthresh_up=255,
mthresh=7,
close=4,
use_otsu=False,
use_hsv=True,
),
tiling_params=make_tiling_params(
spacing=0.5,
tolerance=0.07,
tile_size=224,
overlap=0.0,
min_tissue_percentage=tissue_pct,
drop_holes=False,
use_padding=True,
),
filter_params=make_filter_params(
ref_tile_size=224,
a_t=4,
a_h=2,
max_n_holes=8,
),
sampling_params=make_sampling_params(
pixel_mapping={"background": 0, "tissue": 1},
tissue_percentage={"background": None, "tissue": tissue_pct},
),
disable_tqdm=True,
num_workers=1,
)

generated_path = tmp_path / "generated-test-wsi.npy"
wsi_api.save_coordinates(
coordinates=coordinates,
contour_indices=contour_indices,
target_spacing=0.5,
tile_level=tile_level,
target_tile_size=224,
resize_factor=resize_factor,
tile_size_lv0=tile_size_lv0,
save_path=generated_path,
)

legacy = np.load(gt_path, allow_pickle=False)
generated = np.load(generated_path, allow_pickle=False)
np.testing.assert_array_equal(generated, legacy)
Loading