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 3 commits into
mainfrom
feat/tiling-qc-emergent-seam-detection
Open

Emergent-seam cut-cell detection for calculate_tiling_qc (dense tissue / wide gaps / single-sided cuts)#1255
timtreis wants to merge 3 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

timtreis and others added 3 commits August 6, 2026 21:46
…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>
@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.41284% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.69%. Comparing base (ca07e64) to head (6613267).

Files with missing lines Patch % Lines
src/squidpy/experimental/tl/_seam.py 97.17% 3 Missing and 2 partials ⚠️
src/squidpy/experimental/tl/_tiling_stitch.py 50.00% 2 Missing and 2 partials ⚠️
src/squidpy/experimental/tl/_tiling_qc.py 96.96% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1255      +/-   ##
==========================================
+ Coverage   77.24%   77.69%   +0.45%     
==========================================
  Files          63       64       +1     
  Lines        9378     9587     +209     
  Branches     1579     1617      +38     
==========================================
+ Hits         7244     7449     +205     
- Misses       1532     1536       +4     
  Partials      602      602              
Files with missing lines Coverage Δ
src/squidpy/experimental/tl/_tiling_qc.py 73.31% <96.96%> (+2.74%) ⬆️
src/squidpy/experimental/tl/_tiling_stitch.py 75.94% <50.00%> (+0.64%) ⬆️
src/squidpy/experimental/tl/_seam.py 97.17% <97.17%> (ø)

... and 1 file with indirect coverage changes

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

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