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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
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 aprocess_list.csvper annotation label, but no visual confirmation that:min_coveragethreshold 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:
tiling.masks.colors. This answers "is each label's mask aligned to the slide?"Previews are driven by the same
preview.save_mask_preview/preview.save_tiling_previewflags I already use; no new config to learn. Sharing my config still fully reproduces the run, previews included.User Stories
tiling.masks.colors, so that I can tell tumor from stroma from necrosis at a glance.tiling.masks.colorsto be omitted from the mask preview, so that the overlay stays consistent with my declared visualization intent.min_coveragethreshold behaved as expected.preview.save_mask_previewandpreview.save_tiling_previewflags, so that I don't have to learn a parallel set of annotation-specific config keys.preview.downsampleandpreview.mask_overlay_alphato apply to annotation previews unchanged, so that preview rendering is consistent across tissue and annotation runs.tissue/Noneflattening rule as the coordinate artifacts, so that a label namedtissue(or the merged case) never has its preview diverge from its coordinates.process_list.csvrow to carry its owntiling_preview_path, so that I can go from any manifest row straight to the matching tiling preview.num_tiles=0with an empty tiling-preview cell but a populated mask-preview path, so that the manifest stays consistent and the skipped preview is explainable.resume,read_coordinates_from, andsave_tilesto remain explicitly unsupported under annotation sampling, so that re-enabling previews doesn't imply those unrelated features now work.min_coverage) to be excluded from previews automatically, so that I only see the classes I'm actually sampling.Implementation Decisions
Scope
Rendering — mask preview
preview/mask/{sample_id}.jpglocation.ResolvedAnnotationMasks(the masks the sampler consumed), not by re-reading the raw mask file.preview.mask_overlay_alpha, colored per label fromtiling.masks.colors(the resolvedcolor_mapping). Labels with a null color are skipped. Reuses the existingsave_overlay_preview/overlay_mask_on_slidecolor-mapping path.Rendering — tiling previews (mirror the artifacts)
draw_grid_from_coordinates(no new color config;tissue_contour_colorremains a mask-contour-only color).Paths
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
PreviewConfig(save_mask_preview,save_tiling_preview,downsample,mask_overlay_alpha) governs annotation previews. Per-label colors come fromtiling.masks.colors. A single declarative config still fully determines preprocessing behavior.Wiring
preview=Nonefor sampling runs and emits thepreviews_skippednotice, and (b) the per-annotation compute path that hardcodes the mask preview path to none.resume,read_coordinates_from, andsave_tilesin the "not yet supported under annotation sampling" set; only previews move out of that set.Manifest (
process_list.csv)tiling_preview_path(per-label rows in PER_ANNOTATION; themergedrow in SINGLE_OUTPUT).num_tiles=0, an emptytiling_preview_path, and a populatedmask_preview_path.Transparent (no special handling)
min_coveragelabels: already dropped fromactive_annotationsand from colored overlay; they receive no preview.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:process_list.csvcolumns: per-rowtiling_preview_path, sharedmask_preview_pathrepeated across a slide's rows;tissue/Nonematches the coordinate artifact paths.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.pyand pin them intests/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.tests/test_overlay_semantics.py,tests/test_fixture_artifacts_regression.py.Out of Scope
color_mapping(rejected: low contrast against a same-colored backdrop; grid stays fixed black).resume,read_coordinates_from, orsave_tilesunder annotation sampling — they remain explicitly unsupported.tissue_contour_color.Further Notes
write_coordinate_previewacceptsannotation,palette,pixel_mapping, andcolor_mapping, andsave_overlay_previewacceptscolor_mapping/pixel_mapping. The work is primarily orchestration wiring, manifest plumbing, and path-rule reuse rather than new rendering code.tissueflattens its preview to the root path exactly where its coordinate artifact already flattens — consistent by construction, not a new edge case.