Skip to content

Add tiling QC metric for tile-boundary segmentation artifacts#1157

Merged
timtreis merged 31 commits into
mainfrom
feature/tiling-qc-v2
May 26, 2026
Merged

Add tiling QC metric for tile-boundary segmentation artifacts#1157
timtreis merged 31 commits into
mainfrom
feature/tiling-qc-v2

Conversation

@timtreis

@timtreis timtreis commented Apr 10, 2026

Copy link
Copy Markdown
Member

Summary

  • Adds sq.experimental.tl.calculate_tiling_qc — per-cell scoring that detects cells artificially cut by tile boundaries during segmentation, using collinearity-based straight-edge detection on contours
  • Adds sq.experimental.pl.tiling_qc — diagnostic plot via spatialdata-plot where the tile grid emerges from high-scoring cells without requiring tile-border metadata
  • Includes cell-aware tiling infrastructure (_tiling.py) for scalable labels-only tile extraction — will be shared with [EXPERIMENTAL]: Integrate cp-measure #982 when merged
  • Scores stored in .obs of a QC AnnData table ({labels_key}_qc) with proper spatialdata_attrs linking and algorithm params in .uns["tiling_qc"]

Metrics

Metric Description
max_straight_edge_ratio Longest collinear boundary segment / equivalent diameter
cardinal_alignment_score Axis-alignment of that segment (1 = cardinal, 0 = diagonal)

timtreis and others added 4 commits April 11, 2026 00:11
Cells segmented in tiles get cut at tile borders, producing fragments
with artificially straight edges. This adds:

- `sq.experimental.tl.calculate_tiling_qc`: per-cell scoring via
  collinearity-based straight-edge detection (max_straight_edge_ratio,
  cardinal_alignment_score, cut_score). Scores stored in .obs of a
  QC AnnData table linked to the labels element via spatialdata_attrs.
  Algorithm parameters recorded in .uns["tiling_qc"].
- `sq.experimental.pl.tiling_qc`: diagnostic plot via spatialdata-plot
  (renders labels coloured by score; tile grid emerges from the data).
- Cell-aware tiling infrastructure (_tiling.py) for scalable
  labels-only tile extraction without materialising full arrays.
- Test fixture with 400x400 dask-backed ellipsoid cells cut by a
  3x3 tile grid, with ground-truth cut/intact classification.
- 35 tests (unit, integration, visual regression).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Bump fixture from 40 cells on 400x400 to 120 cells on 600x600
  for more visible tile-grid pattern in diagnostic plots
- Pin spatialdata-plot>=0.3.3 for correct continuous color rendering
- Regenerate visual reference images
- Use _IMAGE_SIZE constant in centroid bounds test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Apr 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 62.91391% with 168 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.18%. Comparing base (67b2c74) to head (9c36d3b).

Files with missing lines Patch % Lines
src/squidpy/experimental/tl/_tiling_qc.py 64.88% 74 Missing and 18 partials ⚠️
src/squidpy/experimental/im/_tiling.py 59.39% 56 Missing and 11 partials ⚠️
src/squidpy/experimental/pl/_tiling_qc.py 64.70% 3 Missing and 3 partials ⚠️
src/squidpy/_utils.py 66.66% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1157      +/-   ##
==========================================
- Coverage   73.82%   73.18%   -0.64%     
==========================================
  Files          45       48       +3     
  Lines        7013     7463     +450     
  Branches     1188     1265      +77     
==========================================
+ Hits         5177     5462     +285     
- Misses       1349     1483     +134     
- Partials      487      518      +31     
Files with missing lines Coverage Δ
src/squidpy/_utils.py 58.54% <66.66%> (+1.32%) ⬆️
src/squidpy/experimental/pl/_tiling_qc.py 64.70% <64.70%> (ø)
src/squidpy/experimental/im/_tiling.py 59.39% <59.39%> (ø)
src/squidpy/experimental/tl/_tiling_qc.py 64.88% <64.88%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

timtreis and others added 20 commits April 11, 2026 00:43
- JIT-compile the two-pointer collinearity scan with @njit for ~10-50x
  speedup on the per-cell hot path
- Cap contour points at 500 via arc-length resampling to bound O(n²)
- Handle contour closure: scan 3 rotations so straight segments
  crossing the start/end junction are not split
- Vectorise _resample_contour with np.searchsorted (no Python loops)
- Replace _zero_non_owned loop with single np.isin pass
- Add tqdm progress bar that tracks cells (not tiles), updates on
  completion for correct parallel reporting
- Extract _SCORE_COLUMNS / _NAN_SCORES constants to deduplicate
- Precompute segment lengths once across rotations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Drop 29 tests that over-tested private internals. Keep 4 behavioural
tests (output structure, metric discrimination, tiling invariant, error
handling) and 3 visual regression tests (one per score column).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace joblib parallelisation with dask.delayed + dask.compute for
  native integration with dask-backed zarr data. Tiles are scheduled
  as delayed tasks; the dask scheduler handles chunk caching and
  worker management.
- Add n_jobs parameter (default -1 = all CPUs) using a threaded
  scheduler, and an optional dask.distributed.Client parameter for
  cluster execution. Warn via logger when both are specified.
- Add affinity-aware cpu_count() to squidpy/_utils.py that respects
  cgroup limits (SLURM, Docker, taskset) via os.sched_getaffinity,
  replacing multiprocessing.cpu_count throughout the codebase.
- Fix NameError in pl.tiling_qc (**kwargs referenced after removal),
  keep spatialdata_plot import lazy, remove unused typing imports.
- Replace assert with raise ValueError in verify_coverage.
- Add nogil=True to numba collinearity scan for thread parallelism.
- Use public API (sq.experimental.tl/pl) in tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Compresses low scores toward zero so tile-boundary artifacts are
more visually prominent without changing the stored metric values.
Users can pass norm=Normalize() for a linear scale.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reference images from CI runner (ubuntu, py3.12).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reference images from CI runner (ubuntu, py3.12).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ier, nhood_outlier_fraction)

Two-stage spatial-context pipeline after per-tile scoring:
- smoothed_cut_score: cut_score x mean(k=10 neighbor cut_scores)
- is_outlier: MAD-based threshold on smoothed scores (nmads param, default 3)
- nhood_outlier_fraction: fraction of k-neighbors that are outliers

Also: update plot defaults to nhood_outlier_fraction/RdYlGn_r, add clean-dataset
and few-cells edge case tests, generate visual references for new columns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…cells)

Replace rejection sampling with grid-based placement: deterministic,
no collision checking needed, 5x more cells at the same memory footprint.
Regenerate all visual references for the denser fixture.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…y plot titles

- is_outlier now requires both per-cell cut_score AND spatial smoothed
  score to exceed their respective MAD thresholds (AND when both enabled)
- Separate parameters: outlier_use_cut, outlier_use_smoothed, nmads_cut
  (default 1.5), nmads_smoothed (default 3)
- Validation: error on both gates disabled or non-positive nmads
- Plot titles mapped to human-friendly names per score column
- Regenerate visual references

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@timtreis timtreis marked this pull request as ready for review April 16, 2026 12:28
@timtreis timtreis requested a review from selmanozleyen April 16, 2026 12:29
Comment thread src/squidpy/experimental/im/_tiling.py Outdated
Comment thread src/squidpy/experimental/tl/_tiling_qc.py Outdated
Comment thread src/squidpy/experimental/tl/_tiling_qc.py Outdated
Comment thread src/squidpy/experimental/tl/_tiling_qc.py Outdated
@timtreis timtreis requested a review from LLehner May 8, 2026 14:16
…am, MAD constant

- _tiling.py: iterate `sorted(tile_to_cells.items())` instead of scanning the
  full grid; skips empty tiles directly and is sparse-friendly on large images.
- _tiling_qc.py: hoist parameter validation above the dask compute so bad
  inputs fail before tile scoring runs.
- _tiling_qc.py: promote `k = 10` to public parameter `n_neighbors`; document
  the "8 grid neighbours + biological wiggle room" rationale. Renames
  `.uns["tiling_qc"]["nhood_k"]` to `["n_neighbors"]`.
- _tiling_qc.py: extract 1.4826 as module-level `_MAD_TO_SD` constant with a
  comment explaining the MAD-to-SD consistency factor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@timtreis timtreis requested a review from selmanozleyen May 8, 2026 14:46
timtreis added a commit that referenced this pull request May 8, 2026
…ater)

Locally rendered placeholder for TestStitchVisual::test_plot_seam_before_after.
The repo convention is platform-correct baselines downloaded from CI
visual_test_results artifacts; this branch can't get one until either
#1157 merges to main or test.yaml grows a workflow_dispatch trigger.
Once CI runs against this branch, overwrite this PNG with the artifact
version.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@LLehner LLehner left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a general question: how is it currently decided whether a function should go into experimental?

@timtreis

Copy link
Copy Markdown
Member Author

Just a general question: how is it currently decided whether a function should go into experimental?

currently pretty much "anything that actually uses sdata"

Comment thread src/squidpy/_utils.py Outdated
Comment thread src/squidpy/experimental/pl/_tiling_qc.py Outdated
Comment thread src/squidpy/experimental/tl/_tiling_qc.py Outdated
Comment thread src/squidpy/experimental/tl/_tiling_qc.py Outdated
Comment thread src/squidpy/experimental/im/_tiling.py Outdated

@selmanozleyen selmanozleyen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Besides the things I mentioned it looks good to me. Really depends on the feedback this will get, otherwise I feel like it would be early optimization to dig deeper.

timtreis and others added 4 commits May 26, 2026 10:46
- Drop public squidpy._utils.cpu_count, fold affinity-aware logic into
  private _cpu_count helper used by both _get_n_cores and parallelize.
  Route calculate_tiling_qc through _get_n_cores instead.
- Type pl.tiling_qc.score_col as Literal of the six valid columns
  (including is_outlier to support the visual test).
- Drop the client= parameter from calculate_tiling_qc. Match the dask
  ecosystem norm (xarray/dask-image/scanpy/spatialdata): an active
  dask.distributed.Client in scope is picked up automatically by
  dask.compute. New helper _has_distributed_client() only decides
  between distributed and the local threaded fallback.
- Rename adata_key_added to table_key_added (the value indexes
  sdata.tables, not adata).
- Replace digit-extraction scale-picker in compute_cell_info_multiscale
  with shape-based selection: pick the level with the smallest spatial
  size. Robust to non-standard scale-key naming.
Correctness:
- MAD outlier branch: when a gate's MAD is degenerate (<1e-12) treat
  it as a no-op instead of setting is_outlier[:] = False, which had
  poisoned the AND with the other gate. Fall back to all-False only
  when no gate produced a meaningful filter, preserving the
  clean-data semantics.

Robustness:
- _zero_non_owned: replace np.isin with a label LUT for O(n) per-pixel
  cost. Guard against zero-sized tiles (max() raises on empty arrays)
  and fall back to np.isin when max_id >= tile_labels.size so sparse-
  but-large ID spaces (e.g. globally-unique segmentation IDs) don't
  allocate an oversized LUT.
- compute_cell_info_tiled: cast bbox/area to float on insert so the
  list[float] type hint is honest.

API:
- Make TileSpec.owned_ids required (drop the field default).
- Expose max_contour_points on calculate_tiling_qc (was a private
  module constant), plumbed through to _score_tile and
  _longest_collinear_segment; recorded in adata.uns["tiling_qc"].
- Warn when scale= is passed to single-scale labels.

Docs & tests:
- Add an Experimental section to docs/api.md listing
  calculate_tiling_qc and tiling_qc.
- Add TestComputeCellInfoTiled covering the tiled-centroid path
  against compute_cell_info as the reference, at small (cross-chunk)
  and single-chunk granularity.
- Inline comments: explain ImportError guard in
  _has_distributed_client; document worker-context deadlock caveat
  in calculate_tiling_qc Notes.
Five Sphinx warnings (treated as errors by ``-W``) on the docs build:

- ``:mod:`spatialdata_plot``` and ``:meth:`spatialdata.SpatialData.pl.show```
  have no intersphinx targets.
- ``:class:`dask.distributed.Client``` has no intersphinx target either.
- ``:func:`~squidpy.experimental.im.calculate_image_features``` pointed at
  a function that does not exist on this branch (planned for PR #982).

Downgrade all five to plain double-backtick literals.  Re-phrase the
calculate_image_features reference to describe the shared tiling
infrastructure (``squidpy.experimental.im._tiling``) without claiming
a public function that has not yet shipped.
@timtreis timtreis merged commit 3a825ff into main May 26, 2026
12 of 13 checks passed
@timtreis timtreis deleted the feature/tiling-qc-v2 branch May 26, 2026 19:44
timtreis added a commit that referenced this pull request May 26, 2026
Builds on the tiling QC pipeline from #1157.  Recovers cells that
segmentation tiling broke into 2-4 pieces by detecting facing cut
edges across tile boundaries and scoring each candidate pair with a
transparent geometric composite.  Worst case (a 4-tile corner) is
handled.

Two public functions:

- ``squidpy.experimental.tl.stitch_tile_cuts(sdata, labels_key, ...)``
  reads ``is_outlier=True`` cells from the QC table, extracts cut-edge
  candidates via bbox-edge alignment, scores each pair with the mean
  of four geometric features in [0, 1] (``iou``, ``endpoint_match``,
  ``merge_compactness``, ``merge_solidity``), and assembles confident
  pairs into 2-4-piece groups via union-find with corner-junction
  validation.  Writes 4 columns into the QC table (``stitch_group_id``,
  ``is_stitched``, ``n_pieces``, ``stitch_confidence``) plus a
  ``.uns["tiling_stitch"]`` audit trail.  The labels element is never
  mutated.

- ``squidpy.experimental.im.make_stitched_labels(sdata, labels_key, ...)``
  opt-in materialisation of a stitched labels element via a lazy dask
  LUT, plus a collapsed AnnData with one row per unique
  ``stitch_group_id``.  Numeric ``.obs`` columns aggregate via
  ``merge_strategy``; group-invariant + non-numeric columns always
  take "first".  Preserves ``.uns``, ``.var``, and user-added columns.

Scoring is heuristic, dataset-independent, and not a calibrated
probability -- no coefficients are fitted or shipped.  The formula
is recorded in ``.uns["tiling_stitch"]["score_formula"]`` so users
can audit and re-derive offline.  ``min_confidence`` thresholds the
mean; ``0.7`` is a starting point that users should tune.

Six internal tunables are exposed on ``stitch_tile_cuts``
(``distance_tol``, ``min_edge_length``, ``min_edge_length_ratio``,
``min_edge_coverage``, ``candidate_min_iou``, ``close_radius``) and
all are recorded in ``.uns["tiling_stitch"]``.

``calculate_tiling_qc`` now emits an actionable warning when re-run
on a QC table whose ``stitch_*`` columns are about to be dropped --
includes the previous stitch parameters and a copy-pasteable
invocation to restore them.  Behaviour is documented in the Notes
block.

The shared multi-scale helper ``resolve_labels_array`` lives in a
private ``experimental.utils._labels`` module (not re-exported), so
both ``_tiling_stitch.py`` and ``_stitched_labels.py`` can import it
without surfacing an internal helper to the public API.

Tests: 41 stitch tests (21 for ``stitch_tile_cuts``, 20 for
``make_stitched_labels``), plus 2 visual baselines.

Note on parallelism: ``stitch_tile_cuts`` is single-threaded by
design -- the algorithm operates on a small candidate-pair set after
outlier filtering, and graph-based union-find doesn't benefit from
dask orchestration.  ``make_stitched_labels`` uses ``dask.array`` for
the lazy LUT-based labels rewrite only.
timtreis added a commit that referenced this pull request May 26, 2026
The rest of squidpy (gr/, im/, etc.) declares module-level constants
inline with at most a one-line comment.  ``_tiling_qc.py`` (from
#1157) and ``_tiling_stitch.py`` shipped with multi-paragraph essays
above each constant duplicating what's already in the public
function's parameter docstring.  Collapse each block to a short
header for the section + bare declarations.
timtreis added a commit that referenced this pull request May 27, 2026
Resolves add/add conflict on src/squidpy/experimental/im/_tiling.py
between our cp_measure-driven lazy tiling refactor and PR #1157's tiling
QC additions. Unified into one module:

* Kept our superset definitions of CellInfo (with bbox_y0/bbox_x0
  defaults), TileSpec, build_tile_specs((shape, cell_info, ...)),
  compute_cell_info, compute_cell_info_multiscale,
  compute_cell_info_tiled, extract_tile, extract_tile_lazy,
  verify_coverage, and the array-returning _zero_non_owned.
* Added extract_labels_tile_lazy(labels_da, spec) -- the labels-only
  crop variant from main, needed by tl/_tiling_qc.py. Implemented on
  top of our _zero_non_owned return style.
* __all__ now exports the new symbol.

Auto-merge restored main's new files (tl/_tiling_qc.py,
pl/_tiling_qc.py, conftest.py, tests/_images/TilingQCVisual_*.png,
test_tiling_qc.py); our earlier deletion of the old tl/_tiling_qc.py
no longer applies -- the new QC implementation supersedes it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants