diff --git a/.github/workflows/pr-test.yaml b/.github/workflows/pr-test.yaml index 2cf0620..e6fc8cb 100644 --- a/.github/workflows/pr-test.yaml +++ b/.github/workflows/pr-test.yaml @@ -1,4 +1,4 @@ -name: Test WSI to coordinates consistency +name: Test revamped suite on: pull_request: @@ -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 @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 6c29da0..411f3aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [tool.pytest.ini_options] addopts = "--cov=hs2p" testpaths = [ - "tests", + "test", ] [tool.mypy] diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..6226ab3 --- /dev/null +++ b/test/conftest.py @@ -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 diff --git a/test/gt/mask-visu.jpg b/test/fixtures/gt/mask-visu.jpg similarity index 100% rename from test/gt/mask-visu.jpg rename to test/fixtures/gt/mask-visu.jpg diff --git a/test/gt/test-wsi.npy b/test/fixtures/gt/test-wsi.npy similarity index 100% rename from test/gt/test-wsi.npy rename to test/fixtures/gt/test-wsi.npy diff --git a/test/gt/tiling-visu.jpg b/test/fixtures/gt/tiling-visu.jpg similarity index 100% rename from test/gt/tiling-visu.jpg rename to test/fixtures/gt/tiling-visu.jpg diff --git a/test/input/test-mask.tif b/test/fixtures/input/test-mask.tif similarity index 100% rename from test/input/test-mask.tif rename to test/fixtures/input/test-mask.tif diff --git a/test/input/test-wsi.tif b/test/fixtures/input/test-wsi.tif similarity index 100% rename from test/input/test-wsi.tif rename to test/fixtures/input/test-wsi.tif diff --git a/test/helpers/fake_wsi_backend.py b/test/helpers/fake_wsi_backend.py new file mode 100644 index 0000000..b7dd125 --- /dev/null +++ b/test/helpers/fake_wsi_backend.py @@ -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]) diff --git a/test/helpers/params.py b/test/helpers/params.py new file mode 100644 index 0000000..3621d99 --- /dev/null +++ b/test/helpers/params.py @@ -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, + ) diff --git a/test/input/config.yaml b/test/input/config.yaml deleted file mode 100644 index b354482..0000000 --- a/test/input/config.yaml +++ /dev/null @@ -1,24 +0,0 @@ -csv: "/input/test.csv" - -output_dir: "/output" -visualize: true - -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 - seg_params: - downsample: 64 # find the closest downsample in the slide for tissue segmentation - filter_params: - ref_tile_size: 224 - sampling_params: - tissue_percentage: # minimum percentage of tile coverage for each category required to sample tiles, leave blank to not sample from a given category - - 'background': - - 'tissue': 0.1 - -speed: - num_workers: 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/test_legacy_coordinates_regression.py b/test/test_legacy_coordinates_regression.py new file mode 100644 index 0000000..271a9db --- /dev/null +++ b/test/test_legacy_coordinates_regression.py @@ -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) diff --git a/test/test_level_selection.py b/test/test_level_selection.py new file mode 100644 index 0000000..54dc22f --- /dev/null +++ b/test/test_level_selection.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +import hs2p.wsi as wsi_api +from hs2p.wsi.wsi import WholeSlideImage +from params import make_filter_params, make_sampling_params, make_segment_params, make_tiling_params + + +def test_get_best_level_for_spacing_returns_within_tolerance_level(fake_backend): + mask = pytest.importorskip("numpy").zeros((16, 16, 1), dtype="uint8") + fake_backend(mask) + wsi = WholeSlideImage(path=Path("synthetic-slide.tif"), backend="asap") + + level, within_tolerance = wsi.get_best_level_for_spacing(target_spacing=2.1, tolerance=0.10) + + assert level == 1 + assert within_tolerance is True + + +def test_get_best_level_for_spacing_falls_back_to_finer_level_when_closest_is_too_coarse(fake_backend): + mask = pytest.importorskip("numpy").zeros((16, 16, 1), dtype="uint8") + fake_backend(mask) + wsi = WholeSlideImage(path=Path("synthetic-slide.tif"), backend="asap") + + level, within_tolerance = wsi.get_best_level_for_spacing(target_spacing=3.5, tolerance=0.01) + + assert level == 1 + assert within_tolerance is False + assert wsi.get_level_spacing(level) <= 3.5 + + +def test_extract_coordinates_raises_when_target_spacing_is_below_level0_beyond_tolerance(monkeypatch): + class GuardOnlyWSI: + def __init__(self, *args, **kwargs): + self.spacings = [1.0] + + monkeypatch.setattr(wsi_api, "WholeSlideImage", GuardOnlyWSI) + + with pytest.raises(ValueError, match="Desired spacing"): + wsi_api.extract_coordinates( + wsi_path=Path("synthetic-slide.tif"), + mask_path=Path("synthetic-mask.tif"), + backend="asap", + segment_params=make_segment_params(), + tiling_params=make_tiling_params(spacing=0.5, tolerance=0.05), + filter_params=make_filter_params(), + sampling_params=make_sampling_params( + pixel_mapping={"background": 0, "tissue": 1}, + tissue_percentage={"background": None, "tissue": 0.0}, + ), + disable_tqdm=True, + num_workers=1, + ) diff --git a/test/test_mocked_multires_tiling.py b/test/test_mocked_multires_tiling.py new file mode 100644 index 0000000..cc7b2db --- /dev/null +++ b/test/test_mocked_multires_tiling.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np + +import hs2p.wsi as wsi_api +from params import make_filter_params, make_sampling_params, make_segment_params, make_tiling_params + + +def test_extract_coordinates_returns_exact_coordinates_for_rectangular_tissue(fake_backend): + mask_l0 = np.zeros((16, 16, 1), dtype=np.uint8) + mask_l0[4:12, 4:12, 0] = 1 + fake_backend(mask_l0) + + coordinates, contour_indices, tile_level, resize_factor, tile_size_lv0 = wsi_api.extract_coordinates( + wsi_path=Path("synthetic-slide.tif"), + mask_path=Path("synthetic-mask.tif"), + backend="asap", + segment_params=make_segment_params(), + tiling_params=make_tiling_params(min_tissue_percentage=0.0), + filter_params=make_filter_params(), + sampling_params=make_sampling_params( + pixel_mapping={"background": 0, "tissue": 1}, + tissue_percentage={"background": None, "tissue": 0.0}, + ), + disable_tqdm=True, + num_workers=1, + ) + + assert coordinates == [(16, 16), (16, 8), (8, 16), (8, 8)] + assert contour_indices == [0, 0, 0, 0] + assert tile_level == 0 + assert resize_factor == 1.0 + assert tile_size_lv0 == 8 + + +def test_extract_coordinates_respects_50_vs_51_percent_tissue_threshold(fake_backend): + mask_l0 = np.zeros((16, 16, 1), dtype=np.uint8) + mask_l0[4:12, 4:10, 0] = 1 # produces two 100%-tissue tiles and two 50%-tissue tiles + fake_backend(mask_l0) + + sampling_at_50 = make_sampling_params( + pixel_mapping={"background": 0, "tissue": 1}, + tissue_percentage={"background": None, "tissue": 0.50}, + ) + sampling_above_50 = make_sampling_params( + pixel_mapping={"background": 0, "tissue": 1}, + tissue_percentage={"background": None, "tissue": 0.51}, + ) + + coordinates_50, *_ = wsi_api.extract_coordinates( + wsi_path=Path("synthetic-slide.tif"), + mask_path=Path("synthetic-mask.tif"), + backend="asap", + segment_params=make_segment_params(), + tiling_params=make_tiling_params(min_tissue_percentage=0.50), + filter_params=make_filter_params(), + sampling_params=sampling_at_50, + disable_tqdm=True, + num_workers=1, + ) + coordinates_51, *_ = wsi_api.extract_coordinates( + wsi_path=Path("synthetic-slide.tif"), + mask_path=Path("synthetic-mask.tif"), + backend="asap", + segment_params=make_segment_params(), + tiling_params=make_tiling_params(min_tissue_percentage=0.51), + filter_params=make_filter_params(), + sampling_params=sampling_above_50, + disable_tqdm=True, + num_workers=1, + ) + + assert coordinates_50 == [(16, 16), (16, 8), (8, 16), (8, 8)] + assert coordinates_51 == [(8, 16), (8, 8)] + assert len(coordinates_51) < len(coordinates_50) + + +def test_extract_coordinates_match_expected_coordinates_across_spacings(fake_backend): + mask_l0 = np.zeros((16, 16, 1), dtype=np.uint8) + mask_l0[4:12, 4:12, 0] = 1 + fake_backend(mask_l0) + + expected = { + 1.0: { + "coordinates": [(16, 16), (16, 8), (8, 16), (8, 8)], + "tile_level": 0, + "resize_factor": 1.0, + "tile_size_lv0": 8, + }, + 1.5: { + "coordinates": [(20, 20), (20, 8), (8, 20), (8, 8)], + "tile_level": 0, + "resize_factor": 1.5, + "tile_size_lv0": 12, + }, + 2.0: { + "coordinates": [(8, 8)], + "tile_level": 1, + "resize_factor": 1.0, + "tile_size_lv0": 16, + }, + } + + for spacing, exp in expected.items(): + coordinates, _, tile_level, resize_factor, tile_size_lv0 = wsi_api.extract_coordinates( + wsi_path=Path("synthetic-slide.tif"), + mask_path=Path("synthetic-mask.tif"), + backend="asap", + segment_params=make_segment_params(), + tiling_params=make_tiling_params( + spacing=spacing, + tolerance=0.01, + tile_size=8, + overlap=0.0, + min_tissue_percentage=0.0, + drop_holes=False, + use_padding=False, + ), + filter_params=make_filter_params(), + sampling_params=make_sampling_params( + pixel_mapping={"background": 0, "tissue": 1}, + tissue_percentage={"background": None, "tissue": 0.0}, + ), + disable_tqdm=True, + num_workers=1, + ) + + assert coordinates == exp["coordinates"] + assert len(coordinates) == len(exp["coordinates"]) + assert tile_level == exp["tile_level"] + assert resize_factor == exp["resize_factor"] + assert tile_size_lv0 == exp["tile_size_lv0"] diff --git a/test/test_mocked_sampling_filter.py b/test/test_mocked_sampling_filter.py new file mode 100644 index 0000000..7438879 --- /dev/null +++ b/test/test_mocked_sampling_filter.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np + +import hs2p.wsi as wsi_api +from hs2p.wsi.wsi import WholeSlideImage +from params import make_sampling_params, make_segment_params, make_tiling_params + + +def test_filter_coordinates_returns_expected_per_class_subsets(fake_backend): + mask_l0 = np.zeros((16, 16, 1), dtype=np.uint8) + mask_l0[4:8, 4:8, 0] = 1 + mask_l0[4:8, 8:12, 0] = 2 + mask_l0[8:12, 8:10, 0] = 1 + mask_l0[8:12, 10:12, 0] = 2 + fake_backend(mask_l0) + + coordinates = [(8, 8), (16, 8), (8, 16), (16, 16)] + contour_indices = [0, 0, 0, 0] + + filtered, filtered_indices = wsi_api.filter_coordinates( + wsi_path=Path("synthetic-slide.tif"), + mask_path=Path("synthetic-mask.tif"), + backend="asap", + coordinates=coordinates, + contour_indices=contour_indices, + tile_level=0, + segment_params=make_segment_params(), + tiling_params=make_tiling_params(), + sampling_params=make_sampling_params( + pixel_mapping={"background": 0, "tumor": 1, "stroma": 2}, + tissue_percentage={"background": None, "tumor": 0.5, "stroma": 0.5}, + ), + disable_tqdm=True, + ) + + assert filtered["tumor"] == [(8, 8), (16, 16)] + assert filtered["stroma"] == [(16, 8), (16, 16)] + assert filtered_indices["tumor"] == [0, 0] + assert filtered_indices["stroma"] == [0, 0] + + +def test_load_segmentation_preserves_discrete_labels_with_nearest_neighbor(fake_backend): + mask_l0 = np.zeros((16, 16, 1), dtype=np.uint8) + mask_l0[4:12, 4:8, 0] = 1 + mask_l0[4:12, 8:12, 0] = 2 + fake_backend(mask_l0) + + sampling_params = make_sampling_params( + pixel_mapping={"background": 0, "tumor": 1, "stroma": 2}, + tissue_percentage={"background": None, "tumor": 0.0, "stroma": 0.0}, + ) + + wsi = WholeSlideImage( + path=Path("synthetic-slide.tif"), + mask_path=Path("synthetic-mask.tif"), + backend="asap", + segment=True, + segment_params=make_segment_params(downsample=1), + sampling_params=sampling_params, + ) + + assert set(np.unique(wsi.annotation_mask["tumor"]).tolist()) <= {0, 255} + assert set(np.unique(wsi.annotation_mask["stroma"]).tolist()) <= {0, 255} diff --git a/test/test_real_fixture_smoke_regression.py b/test/test_real_fixture_smoke_regression.py new file mode 100644 index 0000000..9af38c8 --- /dev/null +++ b/test/test_real_fixture_smoke_regression.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +import hs2p.wsi as wsi_api +from params import make_filter_params, make_sampling_params, make_segment_params, make_tiling_params + + +EXPECTED_TILE_COUNTS = { + 0.10: 459, + 0.50: 407, +} + + +def _choose_backend(wsi_path: Path) -> str: + wsd = pytest.importorskip("wholeslidedata") + for backend in ("asap", "openslide"): + try: + wsd.WholeSlideImage(wsi_path, backend=backend) + return backend + except Exception: + continue + pytest.skip("No supported WholeSlideData backend is available for TIFF fixtures") + + +def _run_extract(wsi_path: Path, mask_path: Path, backend: str, tissue_pct: float): + return 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, + ) + + +def test_real_fixture_is_deterministic_and_matches_expected_counts(real_fixture_paths): + wsi_path, mask_path = real_fixture_paths + backend = _choose_backend(wsi_path) + + run1 = _run_extract(wsi_path, mask_path, backend, tissue_pct=0.10) + run2 = _run_extract(wsi_path, mask_path, backend, tissue_pct=0.10) + + coordinates1, contour_indices1, tile_level1, resize_factor1, tile_size_lv01 = run1 + coordinates2, contour_indices2, tile_level2, resize_factor2, tile_size_lv02 = run2 + + assert coordinates1 == coordinates2 + assert contour_indices1 == contour_indices2 + assert tile_level1 == tile_level2 + assert resize_factor1 == resize_factor2 + assert tile_size_lv01 == tile_size_lv02 + + assert len(coordinates1) == EXPECTED_TILE_COUNTS[0.10] + assert tile_level1 >= 0 + assert tile_size_lv01 > 0 + assert resize_factor1 > 0 + + +def test_real_fixture_stricter_threshold_never_increases_tile_count(real_fixture_paths): + wsi_path, mask_path = real_fixture_paths + backend = _choose_backend(wsi_path) + + loose_coordinates, _, _, _, _ = _run_extract(wsi_path, mask_path, backend, tissue_pct=0.10) + strict_coordinates, _, _, _, _ = _run_extract(wsi_path, mask_path, backend, tissue_pct=0.50) + + assert len(loose_coordinates) == EXPECTED_TILE_COUNTS[0.10] + assert len(strict_coordinates) == EXPECTED_TILE_COUNTS[0.50] + assert len(strict_coordinates) <= len(loose_coordinates) diff --git a/test/test_sort_and_mask_utils.py b/test/test_sort_and_mask_utils.py new file mode 100644 index 0000000..d75d35b --- /dev/null +++ b/test/test_sort_and_mask_utils.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import numpy as np + +from hs2p.wsi import get_mask_coverage, sort_coordinates_with_tissue + + +def test_sort_coordinates_with_tissue_deduplicates_and_orders_deterministically(): + coordinates = [(10, 0), (2, 2), (10, 0), (1, 9)] + tissue_percentages = [0.1, 0.2, 0.3, 0.4] + contour_indices = [5, 6, 7, 8] + + sorted_coords, sorted_tissue, sorted_contours = sort_coordinates_with_tissue( + coordinates, + tissue_percentages, + contour_indices, + ) + + assert sorted_coords == [(10, 0), (1, 9), (2, 2)] + assert sorted_tissue == [0.1, 0.4, 0.2] + assert sorted_contours == [5, 8, 6] + + +def test_get_mask_coverage_returns_exact_fraction_for_label_value(): + mask = np.array( + [ + [0, 1, 1, 2], + [2, 2, 1, 1], + ], + dtype=np.uint8, + ) + + assert get_mask_coverage(mask, 1) == 0.5 + assert get_mask_coverage(mask, 2) == 0.375 + assert get_mask_coverage(mask, 3) == 0.0