Add tiling QC metric for tile-boundary segmentation artifacts#1157
Merged
Conversation
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>
for more information, see https://pre-commit.ci
- 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>
for more information, see https://pre-commit.ci
Codecov Report❌ Patch coverage is 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
🚀 New features to boost your workflow:
|
- 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>
for more information, see https://pre-commit.ci
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>
for more information, see https://pre-commit.ci
- 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>
for more information, see https://pre-commit.ci
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>
for more information, see https://pre-commit.ci
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>
for more information, see https://pre-commit.ci
…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>
for more information, see https://pre-commit.ci
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…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
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
approved these changes
May 10, 2026
LLehner
left a comment
Member
There was a problem hiding this comment.
Just a general question: how is it currently decided whether a function should go into experimental?
Member
Author
currently pretty much "anything that actually uses sdata" |
selmanozleyen
approved these changes
May 13, 2026
selmanozleyen
left a comment
Member
There was a problem hiding this comment.
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.
- 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
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>
This was referenced May 27, 2026
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.
Summary
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 contourssq.experimental.pl.tiling_qc— diagnostic plot via spatialdata-plot where the tile grid emerges from high-scoring cells without requiring tile-border metadata_tiling.py) for scalable labels-only tile extraction — will be shared with [EXPERIMENTAL]: Integrate cp-measure #982 when merged.obsof a QC AnnData table ({labels_key}_qc) with properspatialdata_attrslinking and algorithm params in.uns["tiling_qc"]Metrics
max_straight_edge_ratiocardinal_alignment_score