Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1255 +/- ##
==========================================
+ Coverage 78.44% 79.09% +0.65%
==========================================
Files 63 64 +1
Lines 9532 9706 +174
Branches 1594 1617 +23
==========================================
+ Hits 7477 7677 +200
+ Misses 1489 1469 -20
+ Partials 566 560 -6
🚀 New features to boost your workflow:
|
…ing_qc The MAD-based `is_outlier` gate flags cells with unusually straight boundaries, but in dense tissue with a wide inter-FOV gap -- and when a cut leaves only one segmentable half -- that signal is swamped: most real cut cells are missed and interior cells are flagged instead (recall ~0.1 on a dense/wide-gap synthetic). Add a complementary, geometry-only detector (`detect_seams=True`, on by default) that needs neither the FOV size nor tile overlap and works on single-sided cuts: 1. per cell, find the longest cardinal flat boundary run, keeping only runs with a wide background gap beyond them (a real cut faces a gap; a dense-tissue facet faces a neighbour ~1px away); 2. seam bands emerge as the coordinates where many such edges align, with the band width read off from the peak spread (recovers the seam grid from the data -- no known FOV size); 3. flag cells whose edge lies on and faces a seam as `is_seam_cut`. New: `SeamDetectionParams`, `detect_seams` / `seam_params` args, obs columns `is_seam_cut` / `seam_dist`, and `uns["tiling_qc"]["seams"]`. Existing columns and behaviour are unchanged (additive). On a dense + wide-gap + single-sided synthetic fixture, `is_seam_cut` reaches F1 ~0.85 (recall ~0.95) where `is_outlier` sits at F1 ~0.2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the absolute-pixel thresholds (which would not transfer across resolution / cell size / FOV pitch) with data-derived ones, and fix two recall/precision failures found on real CosMx breast data: - All length thresholds are now fractions of the data's own length scale D (median cell equivalent diameter); flat_tol / bin_width remain small pixel-grid constants (rasterisation / histogram resolution), which are genuinely resolution-independent. - Keep ALL cardinal flat edges per cell (dominant-edge-only dropped a wide cell's on-seam cut edge in favour of an off-seam facet), and find each side's dominant flat *line* at any coordinate (not just the extreme), so a partial cut plateau on a large cell is captured. - Two stages: (1) locate seams from wide-gap edges only -- a lone straight membrane between two touching cells has a ~1px gap and is excluded, so it cannot define a seam; (2) flag any cell with a cardinal edge on a detected seam, gap no longer required, catching partial-edge and close-gap two-sided cuts. The wide-gap threshold derives from the observed membrane width. - Seam peaks must be strong relative to the strongest peak on their axis (rejects stray off-grid membranes whether there is one seam or many). On a real breast 2x2 subset this recovers only the true seam (no spurious seams) with better seam-concentration than before, and on the dense/wide-gap/ single-sided synthetic keeps recall ~0.97-1.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
assign_stitch_groups gated candidates on the MAD `is_outlier` flag, which fires tissue-wide in dense tissue -> facing-edge pairing merged touching interior cells far from any FOV seam. Add a `candidates` argument (default "auto") that gates on `is_seam_cut` when present -- cells whose straight edge lies on a detected FOV seam -- so the candidate pool is seam-localized and pairs form across the seam, not in FOV interiors. Falls back to `is_outlier` when seam detection wasn't run. Also fixes a variable-shadowing bug in calculate_tiling_qc where the new SeamScale object was bound to `scale`, overwriting the multi-scale level argument recorded in uns (broke assign_stitch_groups on multi-scale labels). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…titch_groups Cut-edge extraction still used bbox-edge contour runs with absolute-pixel thresholds (max_gap=3, min_edge_length=5, close_radius=3), so it merged touching interior cells and missed genuine cuts whose seam gap was wider than the hand-tuned radius. Rebuild the pairing stage on the seam bands `calculate_tiling_qc(detect_seams=True)` already records: - Extraction reuses `cell_flat_edges` and keeps only edges lying on and facing a detected seam band -- the same test as `flag_cells_on_seams`, so flagging and pairing agree on what a seam cut is. Fringe-robust: the flat line is found at any coordinate, not pinned to the bbox extreme, so a 1-px segmentation fringe no longer drops an edge. Drops `find_contours` / `_bbox_edge_run` and the `distance_tol` / `min_edge_length` / `min_edge_length_ratio` / `min_edge_coverage` knobs. - Candidate enumeration is rank-based (`k_neighbors`, the k nearest facing edges by perpendicular gap) instead of an absolute `max_gap` search radius, so it adapts to the dataset's own seam-gap width. `max_gap` is removed; the remaining plausibility guard is `max_gap_frac * D` (D = median cell diameter, from uns). - The closing radius is chosen per pair to bridge that pair's own gap (`max(close_radius_min, ceil(gap/2)+1)`), which makes `gap_proximity` actively harmful -- it penalised wide-but-genuine seams -- so the score is now the flat mean of four features, and `min_confidence` defaults to 0.6. `assign_stitch_groups` now requires seam detection and raises a pointed error otherwise. `calculate_tiling_qc` records `seam_diameter` / `seam_membrane` in uns to supply the length scale, `cell_flat_edges` returns each edge's along-seam `extent`, and the seam-band background is the median over all bins (median over nonzero bins was dominated by the seam bins themselves on clean data, rejecting the very seams we want). New `TestPairingContract` locks the two-sided merge contract in at function level on a hand-built cut: a dense synthetic fixture is a poor end-to-end vehicle here because its uniform geometry makes seam *detection* over-flag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2bbf4XFJG1uCdQMuwEZHL
…ling Reviewing the emergent-seam detector turned up a processing artifact, and a set of thresholds that had been tuned against data containing it. `_probe_gap` measured background on the tile-masked label crop, where cells owned by adjacent tiles are zeroed. A cell near its tile's base boundary read its neighbour as open background, passed the wide-gap filter, and voted for a seam on the QC tile grid: on the dense fixture `tile_size=100` reported seams at 99/203/399 -- the tile borders -- alongside the true 140/280, and `tile_size=128` invented one at 383. Gap probing now runs on an occupancy array taken from the unmasked crop (`extract_labels_tile_with_occupancy`; one materialisation, 1 byte/px). Detection is now identical across tile_size 100..420 on both fixtures and across 2048/3000 on real CosMx breast tissue. That artifact had also been propping up the sparse fixture, whose true seams sit exactly on the tile_size=200 grid its tests use. With the leak closed it detected nothing, which exposed the second problem: the wide-gap pre-filter is density-dependent. In packed tissue a wide gap is rare and isolates the seam (4.1% of edges on real CosMx, 5.5% on the dense fixture); in sparse tissue nearly every edge faces open background (45-48%) and the filter discriminates nothing. It is now applied only while it is selective, and the split itself comes from an Otsu partition of the observed gap distribution instead of a multiple of an estimated membrane width. Peak selection became a significance test: a bin is a seam when its aligned-edge count is too large for a Poisson null built from that axis' edge count and extent, Bonferroni-corrected over bins. This replaces `bg_multiple` (measured inert on dense tissue and dominant on sparse -- it flipped role with density), `min_seam_edge_frac` (never binding on either fixture), and `seam_strength_frac`, which judged a seam against the strongest peak on its axis and so could drop a weakly-populated one. `edge_len_frac` drops 0.5 -> 0.25, since a cut rarely bisects a cell through its widest point. Against seam-adjacency ground truth on real CosMx this moves recall 0.38 -> 0.54 at precision 0.98 -> 0.89 (F1 0.54 -> 0.67, against 0.23 for the MAD `is_outlier` gate); on both synthetic fixtures F1 moves by under 0.01, and the no-seam control stays empty at every value. `flat_tol` deliberately stays 1.5px: loosening it to 3.0 drops recall to 0.25 and loses a seam outright, which is precisely why it must not scale with cell size. In the stitcher, how far apart two halves of one cut may lie is now the width of the seam band they sit on rather than `max_gap_frac * D`. The closing radius is scaled to each pair's own gap, so the old bound licensed a disk big enough to bridge 1.5 cell diameters: two blobs 29px apart across a 20px seam scored 0.772 and merged. SeamDetectionParams goes from 12 knobs to 10 and StitchParams from 4 to 3, but the point is that the three thresholds doing the work are now derived per dataset. SeamScale and estimate_membrane_width go with their last consumer. Tests: tile_size and overlap_margin invariance, sparse-tissue detection, the Otsu / selectivity / significance helpers, and the seam-width merge bound. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2bbf4XFJG1uCdQMuwEZHL
timtreis
force-pushed
the
feat/tiling-qc-emergent-seam-detection
branch
from
September 18, 2026 22:25
6613267 to
4adaf74
Compare
… closing Cleanup pass over the seam detection code, no behaviour change: the real CosMx breast subset returns identical numbers before and after (221 cells flagged, recall 0.537, precision 0.891), and the suite is unchanged at 402 passing. The duplication that mattered was a mirror of `SeamDetectionParams` defaults kept in `_tiling_stitch.py`, which had already drifted -- it still claimed `edge_len_frac = 0.5` after the default moved to 0.25. It was unreachable, so nothing was wrong in practice, but "these mirror the defaults" is a comment that cannot be enforced. The stitcher now rehydrates the real parameters from `.uns["tiling_qc"]["seam_params"]` through the existing `resolve_params`. Two more copies went the same way. The frac-to-pixel arithmetic was written out in three places, so a `SeamScale` now resolves it once and flows to every consumer -- which also collapses `_score_tile`'s four seam scalars to one argument. And the "is this edge on a seam and facing it" predicate, which is the definition the whole feature rests on, was transcribed line-for-line into the stitcher with a comment promising the two stages agree; it is now one `seam_offset` both call. On cost, the closing radius tracks each pair's seam gap, so `binary_closing(mask, disk(r))` was O(N*r^2) exactly on the wide-gap data this feature exists for -- 207 ms per pair at r=28. The distance-transform form is pixel-identical and flat in r. `_dominant_flat_line` ran a Python run-length scan per candidate coordinate, four sides per cell, and is now a single vectorised pass; `_longest_true_run` goes with it. `_otsu_gap_split` delegates to `skimage.filters.threshold_otsu`, already a dependency, deleting ~20 lines of cumsum arithmetic a reviewer would otherwise have to check by hand. Dead code removed: an unused `diameter` parameter, a `(cell_a, cell_b, axis)` dedup the enumerator already guarantees, the `is_outlier` gate fallback (seam data is mandatory, so it always raised first), and a per-tile boolean copy allocated even when seam detection is off. `is_seam_cut` is now derived from `seam_dist.notna()` rather than built as a second parallel column, and `gap_channel`'s whole-slide pass runs once instead of twice. Follow-ups deliberately left out, each worth its own change: batching the per-cell dask reads in `_extract_cut_edges` (~28x read amplification on real zarr), an interval walk for the quadratic `_enumerate_pair_candidates`, and folding the tile's owned labels and neighbourhood context into one object so gap probing cannot reach for the masked array again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2bbf4XFJG1uCdQMuwEZHL
The docs build runs `sphinx-build -W` with `nitpicky = True`, so every unresolved cross-reference is an error. Nine warnings, six of them real. Five came from attribute docstrings opening with `Phrase: description` or `Phrase (unit): description`, which Napoleon reads as Google-style `name (type): description` and then tries to resolve both halves as classes -- "py:class reference target not found: Seam histogram bin width", and another for "px". Reworded so the first line is prose, not a field. One of those, `StitchParams.k_neighbors`, predates the seam work: it arrived with the stitch refactor, which is why this check has been red on the PR since then rather than only recently. The sixth was `SeamScale` as the return annotation of the public `SeamDetectionParams.resolve`, pointing at a class that is not in the rendered API. Renamed to `_resolve`: the resolved pixel thresholds are an internal form that callers never handle -- `.uns` stores the dimensionless fractions -- so exporting the type would have committed us to API nothing consumes. The three remaining warnings locally are `toctree ... notebooks/*`, from the docs/notebooks submodule that readthedocs initialises and a plain checkout does not, so they do not occur in CI. Note for anyone reproducing this: `docs/api` holds generated autosummary stubs and is gitignored, so a rename leaves a stale `.rst` behind that fails to import on the next build. Clear it before trusting the warning count. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2bbf4XFJG1uCdQMuwEZHL
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
calculate_tiling_qc'sis_outliergate flags cells whose boundary is unusually straight (MAD oncut_score). On dense tissue with a wide inter-FOV gap — and when a cut leaves only one segmentable half (common in CosMx: the other half is too small to segment in the adjacent FOV) — that signal is swamped:On a dense + wide-gap + single-sided synthetic fixture with ground truth,
is_outlierrecalls only ~10% of true cut cells (F1 ≈ 0.2), andassign_stitch_groupsrecovers ≈ 0 (it merges touching interior cells instead).What this adds
A complementary, geometry-only detector (
detect_seams=True, on by default) that needs neither the FOV size nor tile overlap, and works on single-sided cuts:is_seam_cut.API (additive, backward-compatible)
calculate_tiling_qc:detect_seams: bool = True,seam_params: SeamDetectionParams | Mapping | None = None.SeamDetectionParamsdataclass (exported fromsquidpy.experimental.tl).is_seam_cut(bool),seam_dist(px, NaN where not flagged).uns["tiling_qc"]["seams"].cut_score,is_outlier, …) and default behaviour are unchanged. Settingdetect_seams=Falsereproduces the old output exactly.Integrated into the existing tiled/lazy path (per-cell edges are computed inside
_score_tilewith the tile origin threaded through; the seam histogram is aggregated globally), so it scales the same way and is validated to detect seams across tile boundaries.Results (ground-truth synthetic, dense + single-sided)
is_outlierF1is_seam_cutF1Recall is a stable 0.94–0.96 across gaps/seeds (vs 0.06–0.27), and the detected seam-band width auto-adapts to the true gap. On a real CosMx breast 2×2-FOV subset the detector recovers the true seam at the correct coordinate with no FOV size given, and its flags are ~3× better localized to the seam than
is_outlier.Tests
tests/experimental/test_seam.py(unit tests for edge extraction / seam detection / flagging + integration on a new dense-seam ground-truth fixture inconftest.py).is_seam_cutrecall ≫is_outlieron the hard fixture, seams recovered near the true border,detect_seams=Falsereproduces the old schema, and param validation.test_tiling_qc.py/test_tiling.py/test_tiling_stitch.pypass unchanged.Notes / follow-ups
assign_stitch_groupsconsumeuns["seams"]to restrict pairing to same-seam facing cells (removing the interior-cell false merges).🤖 Generated with Claude Code