Skip to content

Emergent-seam cut-cell detection for calculate_tiling_qc (dense tissue / wide gaps / single-sided cuts) - #1255

Open
timtreis wants to merge 7 commits into
mainfrom
feat/tiling-qc-emergent-seam-detection
Open

timtreis wants to merge 7 commits into
mainfrom
feat/tiling-qc-emergent-seam-detection

Conversation

@timtreis

@timtreis timtreis commented Aug 6, 2026

Copy link
Copy Markdown
Member

Motivation

calculate_tiling_qc's is_outlier gate flags cells whose boundary is unusually straight (MAD on cut_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:

  • many interior cells in packed tissue have straight, cardinal facets → false positives;
  • real cut cells at a seam are separated by a background strip wider than the pairing tolerance, and single-sided cuts have no partner at all.

On a dense + wide-gap + single-sided synthetic fixture with ground truth, is_outlier recalls only ~10% of true cut cells (F1 ≈ 0.2), and assign_stitch_groups recovers ≈ 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:

  1. Per cell, find the longest cardinal (axis-aligned) flat boundary run, keeping only runs with a wide background gap beyond them — a real seam cut faces a gap; a dense-tissue facet faces a neighbour ~1px away. This one discriminator collapses the false-facet background (e.g. 4791 → 346 candidate edges on the synthetic).
  2. Seam bands emerge as the coordinates where many such edges align. A wide gap spreads a seam's edges into a band, so nearby peaks are clustered and the band width is read off from the spread — recovering the seam grid from the data, no known FOV size.
  3. Flag cells whose edge lies on and faces a detected seam as is_seam_cut.

API (additive, backward-compatible)

  • New params on calculate_tiling_qc: detect_seams: bool = True, seam_params: SeamDetectionParams | Mapping | None = None.
  • New SeamDetectionParams dataclass (exported from squidpy.experimental.tl).
  • New obs columns: is_seam_cut (bool), seam_dist (px, NaN where not flagged).
  • Detected seams recorded in uns["tiling_qc"]["seams"].
  • Existing columns (cut_score, is_outlier, …) and default behaviour are unchanged. Setting detect_seams=False reproduces the old output exactly.

Integrated into the existing tiled/lazy path (per-cell edges are computed inside _score_tile with 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)

gap (px) is_outlier F1 is_seam_cut F1
4 0.11–0.16 0.82–0.83
8 0.12–0.15 0.85–0.88
12 0.30–0.31 0.86–0.87
16 0.34–0.43 0.79–0.80

Recall 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

  • New tests/experimental/test_seam.py (unit tests for edge extraction / seam detection / flagging + integration on a new dense-seam ground-truth fixture in conftest.py).
  • Asserts is_seam_cut recall ≫ is_outlier on the hard fixture, seams recovered near the true border, detect_seams=False reproduces the old schema, and param validation.
  • Existing test_tiling_qc.py / test_tiling.py / test_tiling_stitch.py pass unchanged.

Notes / follow-ups

  • This PR improves detection. Fully repairing single-sided cuts is out of scope and — per the segmentation literature (SpaceTrooper flags; ProSeg/Baysor re-segment from the globally-stitched transcript table) — best done from molecules; a natural follow-up is to let assign_stitch_groups consume uns["seams"] to restrict pairing to same-seam facing cells (removing the interior-cell false merges).

🤖 Generated with Claude Code

@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.20521% with 27 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.09%. Comparing base (8286274) to head (534e3c1).

Files with missing lines Patch % Lines
src/squidpy/experimental/tl/_tiling_stitch.py 82.27% 6 Missing and 8 partials ⚠️
src/squidpy/experimental/tl/_seam.py 94.41% 5 Missing and 5 partials ⚠️
src/squidpy/experimental/tl/_tiling_qc.py 92.50% 2 Missing and 1 partial ⚠️
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     
Files with missing lines Coverage Δ
src/squidpy/experimental/im/_tiling.py 91.05% <100.00%> (+1.98%) ⬆️
src/squidpy/experimental/pl/_tiling_qc.py 64.70% <ø> (ø)
src/squidpy/experimental/tl/_tiling_qc.py 73.18% <92.50%> (+2.61%) ⬆️
src/squidpy/experimental/tl/_seam.py 94.41% <94.41%> (ø)
src/squidpy/experimental/tl/_tiling_stitch.py 81.33% <82.27%> (+6.03%) ⬆️

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

timtreis and others added 5 commits September 18, 2026 23:10
…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
timtreis force-pushed the feat/tiling-qc-emergent-seam-detection branch from 6613267 to 4adaf74 Compare September 18, 2026 22:25
timtreis and others added 2 commits September 19, 2026 01:00
… 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant