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
12 changes: 6 additions & 6 deletions .github/workflows/pr-test.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Test revamped suite
name: Test suite

on:
pull_request:
Expand All @@ -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
Expand Down Expand Up @@ -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"
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 16 additions & 2 deletions hs2p/wsi/wsi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
):
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
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 = [
"test",
"tests",
]

[tool.mypy]
Expand Down
15 changes: 15 additions & 0 deletions tasks/adaptive-contour-thickness-plan.md
Original file line number Diff line number Diff line change
@@ -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`).
5 changes: 5 additions & 0 deletions tasks/lessons.md
Original file line number Diff line number Diff line change
@@ -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.
File renamed without changes.
File renamed without changes
File renamed without changes.
File renamed without changes
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
103 changes: 103 additions & 0 deletions tests/test_visualize_mask_thickness.py
Original file line number Diff line number Diff line change
@@ -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