From 9e4254674e168ee65c1c1a8c19a936b9153796e8 Mon Sep 17 00:00:00 2001 From: clement grisi Date: Sat, 14 Feb 2026 15:03:45 +0100 Subject: [PATCH 1/2] Auto-infer mask contour thickness and consolidate tests folder --- .github/workflows/pr-test.yaml | 10 +- README.md | 2 + hs2p/wsi/wsi.py | 18 ++- pyproject.toml | 2 +- tasks/adaptive-contour-thickness-plan.md | 15 +++ tasks/lessons.md | 5 + {test => tests}/conftest.py | 0 {test => tests}/fixtures/gt/mask-visu.jpg | Bin {test => tests}/fixtures/gt/test-wsi.npy | 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 {test => tests}/helpers/fake_wsi_backend.py | 0 {test => tests}/helpers/params.py | 0 .../test_legacy_coordinates_regression.py | 0 {test => tests}/test_level_selection.py | 0 .../test_mocked_multires_tiling.py | 0 .../test_mocked_sampling_filter.py | 0 .../test_real_fixture_smoke_regression.py | 0 {test => tests}/test_sort_and_mask_utils.py | 0 tests/test_visualize_mask_thickness.py | 103 ++++++++++++++++++ 21 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 tasks/adaptive-contour-thickness-plan.md create mode 100644 tasks/lessons.md rename {test => tests}/conftest.py (100%) rename {test => tests}/fixtures/gt/mask-visu.jpg (100%) rename {test => tests}/fixtures/gt/test-wsi.npy (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%) rename {test => tests}/helpers/fake_wsi_backend.py (100%) rename {test => tests}/helpers/params.py (100%) rename {test => tests}/test_legacy_coordinates_regression.py (100%) rename {test => tests}/test_level_selection.py (100%) rename {test => tests}/test_mocked_multires_tiling.py (100%) rename {test => tests}/test_mocked_sampling_filter.py (100%) rename {test => tests}/test_real_fixture_smoke_regression.py (100%) rename {test => tests}/test_sort_and_mask_utils.py (100%) create mode 100644 tests/test_visualize_mask_thickness.py diff --git a/.github/workflows/pr-test.yaml b/.github/workflows/pr-test.yaml index e6fc8cb..8cb2703 100644 --- a/.github/workflows/pr-test.yaml +++ b/.github/workflows/pr-test.yaml @@ -23,10 +23,10 @@ jobs: - name: Verify required folders exist run: | set -euo pipefail - 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 + test -d tests + test -f tests/fixtures/input/test-wsi.tif + test -f tests/fixtures/input/test-mask.tif + test -f tests/fixtures/gt/test-wsi.npy - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -61,4 +61,4 @@ jobs: -v "$GITHUB_WORKSPACE:/workspace" \ -w /workspace \ hs2p:${{ github.sha }} \ - bash -lc "python -m pip install --no-cache-dir pytest pytest-cov && MPLCONFIGDIR=/tmp/mpl python -m pytest -q test" + bash -lc "python -m pip install --no-cache-dir pytest pytest-cov && MPLCONFIGDIR=/tmp/mpl python -m pytest -q tests" diff --git a/README.md b/README.md index 55c623e..f128f52 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,8 @@ If `visualize` is set to `true`, a `visualization/` folder is created containing - **`mask/`**: visualizations of the provided tissue (or annotation) mask - **`tiling/`** (for `tiling.py`) or **`sampling/`** (for `sampling.py`): visualizations of the extracted or sampled tiles overlaid on the slide. For `sampling.py`, this includes subfolders for each category defined in the sampling parameters (e.g., tumor, stroma, etc.) +Mask contour line thickness is automatically inferred from the whole-slide dimensions and the visualization level, so contour readability stays consistent across tiny biopsies and large resections. + For sampling visualizations, overlays are drawn only for annotations that have a non-null color in `sampling_params.color_mapping`. Annotations with null color are left untouched (raw slide pixels, no darkening overlay). These visualizations are useful for double-checking that the tiling or sampling process ran as expected. diff --git a/hs2p/wsi/wsi.py b/hs2p/wsi/wsi.py index da8d5e6..5a3f8d7 100644 --- a/hs2p/wsi/wsi.py +++ b/hs2p/wsi/wsi.py @@ -406,7 +406,6 @@ def visualize_mask( downsample: int = 32, color: tuple[int] = (0, 255, 0), hole_color: tuple[int] = (0, 0, 255), - line_thickness: int = 250, max_size: int | None = None, number_contours: bool = False, ): @@ -420,7 +419,10 @@ def visualize_mask( img = np.ascontiguousarray(img) offset = tuple(-(np.array((0, 0)) * scale).astype(int)) - line_thickness = int(line_thickness * math.sqrt(scale[0] * scale[1])) + line_thickness = self._infer_contour_thickness( + level0_dimensions=self.level_dimensions[0], + scale=(scale[0], scale[1]), + ) if contours is not None: if not number_contours: cv2.drawContours( @@ -479,6 +481,18 @@ def visualize_mask( return img + @staticmethod + def _infer_contour_thickness( + *, + level0_dimensions: tuple[int, int], + scale: tuple[float, float], + ) -> int: + level0_w, level0_h = level0_dimensions + scale_x, scale_y = scale + visual_max_dim = max(level0_w, level0_h) * math.sqrt(scale_x * scale_y) + thickness = round(15 * math.sqrt(visual_max_dim / 864)) + return max(2, min(24, thickness)) + def get_tile_coordinates( self, tiling_params: TilingParameters, diff --git a/pyproject.toml b/pyproject.toml index 411f3aa..6c29da0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [tool.pytest.ini_options] addopts = "--cov=hs2p" testpaths = [ - "test", + "tests", ] [tool.mypy] diff --git a/tasks/adaptive-contour-thickness-plan.md b/tasks/adaptive-contour-thickness-plan.md new file mode 100644 index 0000000..8512ec9 --- /dev/null +++ b/tasks/adaptive-contour-thickness-plan.md @@ -0,0 +1,15 @@ +# Adaptive Contour Thickness Plan + +- [x] Add failing tests for adaptive contour thickness behavior in `tests/test_visualize_mask_thickness.py` +- [x] Implement automatic contour thickness inference in `hs2p/wsi/wsi.py` +- [x] Remove manual `line_thickness` from `WholeSlideImage.visualize_mask` public API +- [x] Update visualization documentation in `README.md` +- [x] Run focused and relevant regression tests +- [x] Mark plan items complete with outcomes + +## Outcomes + +- Added comprehensive thickness tests, including API breakage test for `line_thickness`. +- Contour thickness is now always inferred from whole-slide size and visualization level. +- Verified fixture calibration target remains at thickness `15` for `test-wsi.tif` at the current visualization scale. +- Verified visualization output generation path with fixture files (`/tmp/hs2p-mask-visu-auto-thickness.jpg`). diff --git a/tasks/lessons.md b/tasks/lessons.md new file mode 100644 index 0000000..a3788cd --- /dev/null +++ b/tasks/lessons.md @@ -0,0 +1,5 @@ +# Lessons + +## 2026-02-14 + +- For mask contour rendering, do not keep manual thickness overrides when slide-scale consistency is required. Prefer mandatory auto-inference from level-0 WSI dimensions and visualization level. diff --git a/test/conftest.py b/tests/conftest.py similarity index 100% rename from test/conftest.py rename to tests/conftest.py diff --git a/test/fixtures/gt/mask-visu.jpg b/tests/fixtures/gt/mask-visu.jpg similarity index 100% rename from test/fixtures/gt/mask-visu.jpg rename to tests/fixtures/gt/mask-visu.jpg diff --git a/test/fixtures/gt/test-wsi.npy b/tests/fixtures/gt/test-wsi.npy similarity index 100% rename from test/fixtures/gt/test-wsi.npy rename to tests/fixtures/gt/test-wsi.npy diff --git a/test/fixtures/gt/tiling-visu.jpg b/tests/fixtures/gt/tiling-visu.jpg similarity index 100% rename from test/fixtures/gt/tiling-visu.jpg rename to tests/fixtures/gt/tiling-visu.jpg diff --git a/test/fixtures/input/test-mask.tif b/tests/fixtures/input/test-mask.tif similarity index 100% rename from test/fixtures/input/test-mask.tif rename to tests/fixtures/input/test-mask.tif diff --git a/test/fixtures/input/test-wsi.tif b/tests/fixtures/input/test-wsi.tif similarity index 100% rename from test/fixtures/input/test-wsi.tif rename to tests/fixtures/input/test-wsi.tif diff --git a/test/helpers/fake_wsi_backend.py b/tests/helpers/fake_wsi_backend.py similarity index 100% rename from test/helpers/fake_wsi_backend.py rename to tests/helpers/fake_wsi_backend.py diff --git a/test/helpers/params.py b/tests/helpers/params.py similarity index 100% rename from test/helpers/params.py rename to tests/helpers/params.py diff --git a/test/test_legacy_coordinates_regression.py b/tests/test_legacy_coordinates_regression.py similarity index 100% rename from test/test_legacy_coordinates_regression.py rename to tests/test_legacy_coordinates_regression.py diff --git a/test/test_level_selection.py b/tests/test_level_selection.py similarity index 100% rename from test/test_level_selection.py rename to tests/test_level_selection.py diff --git a/test/test_mocked_multires_tiling.py b/tests/test_mocked_multires_tiling.py similarity index 100% rename from test/test_mocked_multires_tiling.py rename to tests/test_mocked_multires_tiling.py diff --git a/test/test_mocked_sampling_filter.py b/tests/test_mocked_sampling_filter.py similarity index 100% rename from test/test_mocked_sampling_filter.py rename to tests/test_mocked_sampling_filter.py diff --git a/test/test_real_fixture_smoke_regression.py b/tests/test_real_fixture_smoke_regression.py similarity index 100% rename from test/test_real_fixture_smoke_regression.py rename to tests/test_real_fixture_smoke_regression.py diff --git a/test/test_sort_and_mask_utils.py b/tests/test_sort_and_mask_utils.py similarity index 100% rename from test/test_sort_and_mask_utils.py rename to tests/test_sort_and_mask_utils.py diff --git a/tests/test_visualize_mask_thickness.py b/tests/test_visualize_mask_thickness.py new file mode 100644 index 0000000..57ccfa8 --- /dev/null +++ b/tests/test_visualize_mask_thickness.py @@ -0,0 +1,103 @@ +from pathlib import Path + +import numpy as np +import pytest + + +wsi_mod = pytest.importorskip("hs2p.wsi.wsi") + + +def test_infer_contour_thickness_is_monotonic_for_slide_size(): + small = wsi_mod.WholeSlideImage._infer_contour_thickness( + level0_dimensions=(2048, 1536), + scale=(1 / 16.0, 1 / 16.0), + ) + large = wsi_mod.WholeSlideImage._infer_contour_thickness( + level0_dimensions=(65536, 49152), + scale=(1 / 16.0, 1 / 16.0), + ) + + assert small < large + + +def test_infer_contour_thickness_clamps_to_bounds(): + min_thickness = wsi_mod.WholeSlideImage._infer_contour_thickness( + level0_dimensions=(64, 64), + scale=(1 / 128.0, 1 / 128.0), + ) + max_thickness = wsi_mod.WholeSlideImage._infer_contour_thickness( + level0_dimensions=(262144, 262144), + scale=(1.0, 1.0), + ) + + assert min_thickness == 2 + assert max_thickness == 24 + + +def test_infer_contour_thickness_matches_fixture_calibration(): + thickness = wsi_mod.WholeSlideImage._infer_contour_thickness( + level0_dimensions=(13824, 12800), + scale=(1 / 16.0, 1 / 16.0), + ) + + assert thickness == 15 + + +def test_visualize_mask_rejects_line_thickness_argument(): + wsi = object.__new__(wsi_mod.WholeSlideImage) + wsi.level_dimensions = [(64, 64)] + wsi.level_downsamples = [(1.0, 1.0)] + wsi.spacings = [0.5] + wsi.backend = "asap" + wsi.get_best_level_for_downsample_custom = lambda downsample: 0 + class _Reader: + def get_slide(self, spacing): + return np.zeros((64, 64, 3), dtype=np.uint8) + + wsi.wsi = _Reader() + + contour = np.array([[[5, 5]], [[20, 5]], [[20, 20]], [[5, 20]]], dtype=np.int32) + + with pytest.raises(TypeError): + wsi.visualize_mask([contour], [[]], line_thickness=5) + + +def test_visualize_mask_passes_auto_inferred_thickness_to_draw(monkeypatch): + captured = [] + original_draw_contours = wsi_mod.cv2.drawContours + + def _capture(img, contours, contourIdx, color, thickness, *args, **kwargs): + captured.append(thickness) + return original_draw_contours(img, contours, contourIdx, color, thickness, *args, **kwargs) + + monkeypatch.setattr(wsi_mod.cv2, "drawContours", _capture) + + def _make_wsi(level0_dims): + wsi = object.__new__(wsi_mod.WholeSlideImage) + wsi.level_dimensions = [level0_dims, (int(level0_dims[0] / 16), int(level0_dims[1] / 16))] + wsi.level_downsamples = [(1.0, 1.0), (16.0, 16.0)] + wsi.spacings = [0.5, 8.0] + wsi.backend = "asap" + wsi.get_best_level_for_downsample_custom = lambda downsample: 1 + class _Reader: + def get_slide(self, spacing): + return np.zeros((int(level0_dims[1] / 16), int(level0_dims[0] / 16), 3), dtype=np.uint8) + + wsi.wsi = _Reader() + return wsi + + contour = np.array([[[32, 32]], [[256, 32]], [[256, 256]], [[32, 256]]], dtype=np.int32) + + small_wsi = _make_wsi((2048, 1536)) + small_wsi.visualize_mask([contour], [[]]) + + large_wsi = _make_wsi((65536, 49152)) + large_wsi.visualize_mask([contour], [[]]) + + # First draw of each visualize call is tissue contour. + small_thickness = captured[0] + large_thickness = captured[2] + + assert 2 <= small_thickness <= 24 + assert 2 <= large_thickness <= 24 + assert large_thickness > small_thickness From 0a093a60becf4cb8019ebc6d3f562630140f2eb2 Mon Sep 17 00:00:00 2001 From: clement grisi Date: Sat, 14 Feb 2026 15:08:13 +0100 Subject: [PATCH 2/2] minor change --- .github/workflows/pr-test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-test.yaml b/.github/workflows/pr-test.yaml index 8cb2703..762d3b0 100644 --- a/.github/workflows/pr-test.yaml +++ b/.github/workflows/pr-test.yaml @@ -1,4 +1,4 @@ -name: Test revamped suite +name: Test suite on: pull_request: