Skip to content

Annotation-aware previews: re-enable mask + tiling previews for annotation sampling #141

Description

@clemsgrs

Problem Statement

When I run annotation-aware sampling (declaring non-tissue classes under tiling.masks), hs2p silently turns off the preview images it normally produces. I get coordinate sets and a process_list.csv per annotation label, but no visual confirmation that:

  • each annotation label's mask is actually aligned to the slide, and
  • the sampled tiles landed where that annotation really is (respecting each label's min_coverage threshold and the chosen selection strategy).

For ordinary tissue tiling I get both a mask preview and a tiling preview to QA the run at a glance. For annotation sampling — the more error-prone path, with per-label coverage thresholds, INDEPENDENT vs JOINT selection, and PER_ANNOTATION vs SINGLE_OUTPUT output modes — I'm flying blind and only find problems after downstream training consumes bad tiles. The CLI even defaults previews on and then prints a notice that they were skipped, which is confusing.

Solution

Make previews first-class for annotation-aware sampling, mirroring the artifacts that were actually written so a preview always corresponds 1:1 to a saved coordinate set.

For every annotation run I get:

  • One multi-label mask preview per slide (both output modes): the slide with every active annotation region overlaid as a filled, semi-transparent color wash using the colors I declared in tiling.masks.colors. This answers "is each label's mask aligned to the slide?"
  • Tiling previews that mirror the coordinate artifacts:
    • In PER_ANNOTATION mode, one tiling preview per label — that label's mask (full color) as a backdrop with the selected tile grid drawn on top — so I can see whether tiles landed on the annotation region.
    • In SINGLE_OUTPUT mode, one merged tiling preview — the multi-label mask backdrop with the merged (union) tile grid.

Previews are driven by the same preview.save_mask_preview / preview.save_tiling_preview flags I already use; no new config to learn. Sharing my config still fully reproduces the run, previews included.

User Stories

  1. As a computational pathologist, I want a multi-label mask preview for each slide in an annotation run, so that I can confirm every annotation class is aligned to the slide before tiling thousands of slides.
  2. As a pathologist, I want each annotation class overlaid in the color I declared under tiling.masks.colors, so that I can tell tumor from stroma from necrosis at a glance.
  3. As a pathologist, I want classes whose color is null in tiling.masks.colors to be omitted from the mask preview, so that the overlay stays consistent with my declared visualization intent.
  4. As a pathologist, I want the mask preview rendered from the same binary masks the sampler consumed (resolved to the seg level, discrete-label-validated, with backend fallback applied), so that the preview reflects exactly what was sampled rather than a re-read of the raw file.
  5. As a pathologist running PER_ANNOTATION mode, I want one tiling preview per annotation label, so that each preview corresponds 1:1 to that label's saved coordinate set.
  6. As a pathologist, I want each per-label tiling preview to show that label's mask as a backdrop under the tile grid, so that I can verify tiles landed on the annotation region and that the min_coverage threshold behaved as expected.
  7. As a pathologist running SINGLE_OUTPUT mode, I want a single merged tiling preview over a multi-label mask backdrop, so that the preview matches the single merged coordinate artifact that was written.
  8. As a pathologist, I want the tile grid drawn in a fixed high-contrast black line, so that it reads clearly against any annotation color in the backdrop.
  9. As a pathologist, I want a label that sampled zero tiles to have its tiling preview skipped, so that preview output matches existing tissue behavior and I'm not handed empty grids.
  10. As a pathologist, I want previews controlled by the existing preview.save_mask_preview and preview.save_tiling_preview flags, so that I don't have to learn a parallel set of annotation-specific config keys.
  11. As a pathologist, I want preview.downsample and preview.mask_overlay_alpha to apply to annotation previews unchanged, so that preview rendering is consistent across tissue and annotation runs.
  12. As a pathologist, I want preview file paths to follow the same tissue/None flattening rule as the coordinate artifacts, so that a label named tissue (or the merged case) never has its preview diverge from its coordinates.
  13. As a pathologist, I want each process_list.csv row to carry its own tiling_preview_path, so that I can go from any manifest row straight to the matching tiling preview.
  14. As a pathologist, I want the shared per-slide mask preview path repeated on every label row of that slide, so that each manifest row is self-describing.
  15. As a pathologist, I want a zero-tile label's row to keep num_tiles=0 with an empty tiling-preview cell but a populated mask-preview path, so that the manifest stays consistent and the skipped preview is explainable.
  16. As a pathologist, I want previews to work identically whether I use INDEPENDENT or JOINT selection, so that the strategy choice doesn't change my QA workflow.
  17. As a pathologist, I no longer want to see a "previews skipped" notice when previews are enabled, so that the CLI output matches what actually happened.
  18. As a pathologist, I want the per-slide mask preview computed efficiently (once per slide, not once per label), so that annotation runs over large datasets don't pay for redundant renders.
  19. As a pathologist, I want tiling previews generated without serializing on the hot path, so that enabling previews doesn't materially slow a large annotation run.
  20. As a maintainer, I want resume, read_coordinates_from, and save_tiles to remain explicitly unsupported under annotation sampling, so that re-enabling previews doesn't imply those unrelated features now work.
  21. As a maintainer, I want a single end-to-end behavioral contract for annotation previews, so that the feature is pinned by external behavior rather than brittle internals.
  22. As a maintainer, I want the actual rendering captured in the existing visual-fixture regression, so that color, overlay, and grid drift is caught automatically.
  23. As a pathologist, I want the background class (typically null color and null min_coverage) to be excluded from previews automatically, so that I only see the classes I'm actually sampling.

Implementation Decisions

Scope

  • Re-enable both preview types for annotation-aware sampling: a multi-label mask preview and tiling previews.

Rendering — mask preview

  • One multi-label mask preview per slide in both output modes, written to the conventional flat preview/mask/{sample_id}.jpg location.
  • Rendered from the resolved per-label binary masks held in ResolvedAnnotationMasks (the masks the sampler consumed), not by re-reading the raw mask file.
  • Filled, semi-transparent overlay using preview.mask_overlay_alpha, colored per label from tiling.masks.colors (the resolved color_mapping). Labels with a null color are skipped. Reuses the existing save_overlay_preview / overlay_mask_on_slide color-mapping path.

Rendering — tiling previews (mirror the artifacts)

  • PER_ANNOTATION: one tiling preview per active annotation label; backdrop is that label's mask in full color; tile grid drawn over it.
  • SINGLE_OUTPUT: a single merged tiling preview; backdrop is the multi-label mask; grid is the merged (union) tile set.
  • Grid is the existing fixed black line from draw_grid_from_coordinates (no new color config; tissue_contour_color remains a mask-contour-only color).
  • Zero-tile labels: tiling preview skipped (consistent with existing tissue behavior, which only emits a tiling preview when tile count > 0).

Paths

  • Preview subdirectory selection reuses the artifact layer's flattening rule (annotation in {None, "tissue"} → flat root, else .../{annotation}/...), ideally via the same helper used by the coordinate/tar artifacts, so preview paths cannot diverge from coordinate paths.

Config

  • No new config keys. The existing PreviewConfig (save_mask_preview, save_tiling_preview, downsample, mask_overlay_alpha) governs annotation previews. Per-label colors come from tiling.masks.colors. A single declarative config still fully determines preprocessing behavior.

Wiring

  • Remove the two preview suppression points: (a) the CLI path that forces preview=None for sampling runs and emits the previews_skipped notice, and (b) the per-annotation compute path that hardcodes the mask preview path to none.
  • The CLI must keep resume, read_coordinates_from, and save_tiles in the "not yet supported under annotation sampling" set; only previews move out of that set.
  • Mask preview is computed inline, once per slide, at the point the annotation masks are resolved (binary masks already in hand).
  • Tiling previews fan out through the existing preview thread-pool executor — one task per label in PER_ANNOTATION, one task in SINGLE_OUTPUT — reusing the deferred-finalize machinery the tissue path already uses, so renders don't serialize on the compute hot path.

Manifest (process_list.csv)

  • Each row carries its own tiling_preview_path (per-label rows in PER_ANNOTATION; the merged row in SINGLE_OUTPUT).
  • The shared per-slide mask preview path is repeated on every label row of that slide so each row is self-contained.
  • Zero-tile rows keep num_tiles=0, an empty tiling_preview_path, and a populated mask_preview_path.

Transparent (no special handling)

  • INDEPENDENT vs JOINT selection: previews render whatever coordinates the sampler produced.
  • Background / null-color / null-min_coverage labels: already dropped from active_annotations and from colored overlay; they receive no preview.
  • Downsample / seg-level scaling: handled by the existing overlay rendering code.

Testing Decisions

A good test here asserts external behavior at the highest existing seam — the files and manifest a user actually receives — not the internals of the rendering helpers. Rendering helpers (write_coordinate_preview, save_overlay_preview) are exercised transitively, not unit-tested in isolation. No new seams are introduced.

Primary seam — tile_slides() (public orchestration entry):
Drive a small fixture slide plus a multi-label annotation mask through tile_slides(..., sampling=, output_mode=, preview=) and assert the contract from the outside:

  • preview files emitted per output mode (PER_ANNOTATION → N per-label tiling previews + 1 multi-label mask preview; SINGLE_OUTPUT → 1 merged tiling preview + 1 mask preview);
  • process_list.csv columns: per-row tiling_preview_path, shared mask_preview_path repeated across a slide's rows;
  • zero-tile labels skip the tiling preview but keep a manifest row; null-color labels excluded from overlay;
  • preview path flattening for tissue/None matches the coordinate artifact paths.
  • Prior art: tests/test_tiling_api.py, tests/test_unified_independent_sampling.py, tests/test_unified_joint_sampling.py, tests/test_unified_process_list_schema.py.

Secondary seam — visual-fixture regeneration + pixel regression:
Add annotation-preview cases to scripts/regenerate_fixture_visualizations.py and pin them in tests/test_fixture_artifacts_regression.py, so the actual rendering (filled alpha overlay, declared label colors, black grid, mask backdrop) is regenerated once for human review and protected against drift.

  • Prior art: tests/test_overlay_semantics.py, tests/test_fixture_artifacts_regression.py.

Out of Scope

  • A combined single-image overview that color-codes all labels' tile grids on one canvas (z-ordering / overlap handling). Previews stay 1:1 with artifacts.
  • Per-label grid coloring from color_mapping (rejected: low contrast against a same-colored backdrop; grid stays fixed black).
  • Re-deriving per-label tile membership in SINGLE_OUTPUT mode to force per-label previews; the merged preview mirrors the single merged artifact.
  • Contour-style annotation overlays; the mask preview uses a filled alpha overlay.
  • Enabling resume, read_coordinates_from, or save_tiles under annotation sampling — they remain explicitly unsupported.
  • Renaming or repurposing tissue_contour_color.
  • New preview config keys.

Further Notes

  • The rendering layer is already partly wired for this: write_coordinate_preview accepts annotation, palette, pixel_mapping, and color_mapping, and save_overlay_preview accepts color_mapping / pixel_mapping. The work is primarily orchestration wiring, manifest plumbing, and path-rule reuse rather than new rendering code.
  • The merged SINGLE_OUTPUT tiling preview backdrop is the same multi-label mask image as that slide's mask preview, minus the grid. This mild redundancy is intentional: the two images serve distinct manifest rows.
  • Reusing the artifact flattening helper means a label literally named tissue flattens its preview to the root path exactly where its coordinate artifact already flattens — consistent by construction, not a new edge case.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions