From a0a2db06e7e47b0b45343f5e0c958f52fae19f76 Mon Sep 17 00:00:00 2001 From: anon Date: Sat, 30 May 2026 00:59:33 +0200 Subject: [PATCH 01/17] feat(experimental): add assign_stitch_groups for tile-cut stitching Add sq.experimental.tl.assign_stitch_groups + StitchParams: groups tile-cut cell pieces flagged by calculate_tiling_qc by pairing facing cut edges and scoring each pair with a transparent weighted mean of five geometric features (iou, endpoint_match, merge_compactness, merge_solidity, gap_proximity). Only .obs columns + a tiling_stitch audit block are written; the labels element is never modified. Weights default flat-equal and are tunable via feature_weights; no coefficients are fitted or shipped. Also: - wire the calculate_tiling_qc hook warning about dropped stitch columns - extract shared helpers (resolve_params, equivalent_diameter, largest_contour, iter_chunked_regionprops) into experimental/utils, reused by _tiling_qc and _tiling to remove duplication; QC numerics unchanged (visual baselines pass) - save_diagnostics writes a zarr-safe per-pair dict-of-arrays to .uns - clamp merge_solidity and keep gap_proximity neutral when closing is disabled Co-Authored-By: Claude Opus 4.8 --- docs/api.md | 2 + docs/release/notes-dev.md | 4 + src/squidpy/experimental/im/_tiling.py | 54 +- src/squidpy/experimental/tl/__init__.py | 3 +- src/squidpy/experimental/tl/_tiling_qc.py | 67 +- src/squidpy/experimental/tl/_tiling_stitch.py | 1022 +++++++++++++++++ src/squidpy/experimental/utils/_geometry.py | 30 + src/squidpy/experimental/utils/_labels.py | 45 + src/squidpy/experimental/utils/_params.py | 40 + tests/experimental/test_tiling_stitch.py | 535 +++++++++ 10 files changed, 1750 insertions(+), 52 deletions(-) create mode 100644 src/squidpy/experimental/tl/_tiling_stitch.py create mode 100644 src/squidpy/experimental/utils/_geometry.py create mode 100644 src/squidpy/experimental/utils/_params.py create mode 100644 tests/experimental/test_tiling_stitch.py diff --git a/docs/api.md b/docs/api.md index 1bf4fdf92..c701c4058 100644 --- a/docs/api.md +++ b/docs/api.md @@ -149,6 +149,8 @@ See the {doc}`extensibility guide ` for how to implement a custo experimental.tl.calculate_tiling_qc experimental.tl.TilingQCParams + experimental.tl.assign_stitch_groups + experimental.tl.StitchParams experimental.pl.tiling_qc experimental.im.fit_stain_reference experimental.im.apply_stain_normalization diff --git a/docs/release/notes-dev.md b/docs/release/notes-dev.md index e5fda1ca7..00fbf1df4 100644 --- a/docs/release/notes-dev.md +++ b/docs/release/notes-dev.md @@ -2,6 +2,10 @@ ## Features +- Add {func}`squidpy.experimental.tl.assign_stitch_groups` (with {class}`squidpy.experimental.tl.StitchParams`) + to group tile-cut cell pieces flagged by {func}`squidpy.experimental.tl.calculate_tiling_qc`, scoring candidate + pairs with a transparent weighted mean of five geometric features. + [@timtreis](https://github.com/timtreis) - Fix {func}`squidpy.tl.var_by_distance` behaviour when providing {mod}`numpy` arrays of coordinates as anchor point. - Update :attr:`squidpy.pl.var_by_distance` to show multiple variables on same plot. [@LLehner](https://github.com/LLehner) diff --git a/src/squidpy/experimental/im/_tiling.py b/src/squidpy/experimental/im/_tiling.py index 4f6424ee3..912ef1288 100644 --- a/src/squidpy/experimental/im/_tiling.py +++ b/src/squidpy/experimental/im/_tiling.py @@ -19,6 +19,8 @@ import xarray as xr from skimage.measure import regionprops +from squidpy.experimental.utils._labels import iter_chunked_regionprops + @dataclass(frozen=True) class CellInfo: @@ -144,41 +146,29 @@ def compute_cell_info_tiled( chunk_size Size of chunks to read at a time. """ - H = int(labels_da.sizes.get("y", labels_da.shape[-2])) - W = int(labels_da.sizes.get("x", labels_da.shape[-1])) - # Per-label accumulators: [sum_y*area, sum_x*area, total_area, min_y, max_y, min_x, max_x] stats: dict[int, list[float]] = {} - for y0 in range(0, H, chunk_size): - y1 = min(y0 + chunk_size, H) - for x0 in range(0, W, chunk_size): - x1 = min(x0 + chunk_size, W) - chunk = labels_da.isel(y=slice(y0, y1), x=slice(x0, x1)).values - if chunk.ndim > 2: - chunk = chunk.squeeze() - - for p in regionprops(chunk): - lid = p.label - cy_global = float(p.centroid[0] + y0) - cx_global = float(p.centroid[1] + x0) - area = float(p.area) - min_row = float(p.bbox[0] + y0) - max_row = float(p.bbox[2] + y0) - min_col = float(p.bbox[1] + x0) - max_col = float(p.bbox[3] + x0) - - if lid not in stats: - stats[lid] = [cy_global * area, cx_global * area, area, min_row, max_row, min_col, max_col] - else: - s = stats[lid] - s[0] += cy_global * area - s[1] += cx_global * area - s[2] += area - s[3] = min(s[3], min_row) - s[4] = max(s[4], max_row) - s[5] = min(s[5], min_col) - s[6] = max(s[6], max_col) + for lid, p, y0, x0 in iter_chunked_regionprops(labels_da, chunk_size=chunk_size): + cy_global = float(p.centroid[0] + y0) + cx_global = float(p.centroid[1] + x0) + area = float(p.area) + min_row = float(p.bbox[0] + y0) + max_row = float(p.bbox[2] + y0) + min_col = float(p.bbox[1] + x0) + max_col = float(p.bbox[3] + x0) + + if lid not in stats: + stats[lid] = [cy_global * area, cx_global * area, area, min_row, max_row, min_col, max_col] + else: + s = stats[lid] + s[0] += cy_global * area + s[1] += cx_global * area + s[2] += area + s[3] = min(s[3], min_row) + s[4] = max(s[4], max_row) + s[5] = min(s[5], min_col) + s[6] = max(s[6], max_col) result: dict[int, CellInfo] = {} for lid, s in stats.items(): diff --git a/src/squidpy/experimental/tl/__init__.py b/src/squidpy/experimental/tl/__init__.py index 1c2f97ece..7122bd3cd 100644 --- a/src/squidpy/experimental/tl/__init__.py +++ b/src/squidpy/experimental/tl/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations from ._tiling_qc import TilingQCParams, calculate_tiling_qc +from ._tiling_stitch import StitchParams, assign_stitch_groups -__all__ = ["TilingQCParams", "calculate_tiling_qc"] +__all__ = ["StitchParams", "TilingQCParams", "assign_stitch_groups", "calculate_tiling_qc"] diff --git a/src/squidpy/experimental/tl/_tiling_qc.py b/src/squidpy/experimental/tl/_tiling_qc.py index db757fd0e..40b625b9c 100644 --- a/src/squidpy/experimental/tl/_tiling_qc.py +++ b/src/squidpy/experimental/tl/_tiling_qc.py @@ -28,7 +28,7 @@ import math from collections.abc import Mapping -from dataclasses import asdict, dataclass, fields +from dataclasses import asdict, dataclass from typing import Any, Literal import anndata as ad @@ -39,7 +39,7 @@ import xarray as xr from dask.diagnostics import ProgressBar from numba import njit -from skimage.measure import find_contours, regionprops +from skimage.measure import regionprops from sklearn.neighbors import BallTree from spatialdata._logging import logger as logg from spatialdata.models import TableModel @@ -52,7 +52,10 @@ compute_cell_info_tiled, extract_labels_tile_lazy, ) +from squidpy.experimental.tl._tiling_stitch import _STITCH_COLUMNS, _STITCH_PARAM_KEYS, StitchParams +from squidpy.experimental.utils._geometry import equivalent_diameter, largest_contour from squidpy.experimental.utils._labels import resolve_labels_array +from squidpy.experimental.utils._params import resolve_params __all__ = ["TilingQCParams", "calculate_tiling_qc"] @@ -91,23 +94,11 @@ def __post_init__(self) -> None: _QC_DEFAULTS = TilingQCParams() -_QC_FIELDS = frozenset(f.name for f in fields(TilingQCParams)) def _resolve_qc_params(qc_params: TilingQCParams | Mapping[str, Any] | None) -> TilingQCParams: """Normalise the ``tiling_qc_params`` argument to a :class:`TilingQCParams` instance.""" - if qc_params is None: - return _QC_DEFAULTS - if isinstance(qc_params, TilingQCParams): - return qc_params - if isinstance(qc_params, Mapping): - unknown = set(qc_params) - _QC_FIELDS - if unknown: - raise ValueError( - f"Unknown `tiling_qc_params` field(s): {sorted(unknown)}; expected from {sorted(_QC_FIELDS)}." - ) - return TilingQCParams(**qc_params) - raise TypeError(f"`tiling_qc_params` must be TilingQCParams, Mapping, or None; got {type(qc_params).__name__}.") + return resolve_params(qc_params, TilingQCParams, label="`tiling_qc_params`") # Standard consistency factor sd ~ 1.4826 x MAD for normal distributions. @@ -343,7 +334,7 @@ def _straight_edge_metrics( cut_score Product of the two. """ - eq_diam = np.sqrt(4 * cell_area / np.pi) + eq_diam = equivalent_diameter(cell_area) if eq_diam == 0: return 0.0, 0.0, 0.0 @@ -412,12 +403,12 @@ def _score_tile( if downsample > 1: crop = crop[::downsample, ::downsample] - contours = find_contours(crop, 0.5) - if not contours: + # crop is already 1px-padded above (before optional downsample). + contour = largest_contour(crop) + if contour is None: rows[lid] = dict(_NAN_TILE_SCORES) continue - contour = max(contours, key=len) analysis_area = area / (downsample**2) if downsample > 1 else area ser, cas, cs = _straight_edge_metrics(contour, analysis_area, distance_tol, max_contour_points) @@ -733,6 +724,44 @@ def _process_one(spec): if inplace: table_key = table_key_added if table_key_added is not None else f"{labels_key}_qc" + _warn_if_dropping_stitch_columns(sdata, table_key, labels_key) sdata.tables[table_key] = TableModel.parse(adata) return None return adata + + +def _warn_if_dropping_stitch_columns(sdata: sd.SpatialData, table_key: str, labels_key: str) -> None: + """Warn if re-running QC would drop downstream stitch results. + + ``calculate_tiling_qc`` replaces the QC table wholesale, so any columns + added by :func:`~squidpy.experimental.tl.assign_stitch_groups` to a previous + version of this table are about to disappear. We emit an actionable warning + listing the previous stitch parameters (from ``.uns["tiling_stitch"]``) and a + copy-pasteable invocation to restore them. + """ + if table_key not in sdata.tables: + return + existing = sdata.tables[table_key] + present = [c for c in _STITCH_COLUMNS if c in existing.obs.columns] + if not present: + return + + prev_params = existing.uns.get("tiling_stitch", {}) if hasattr(existing, "uns") else {} + # `tiling_stitch` mixes top-level constructor kwargs with the nested + # ``stitch_params`` bundle and diagnostic outputs (n_outliers, ...). + # Filter to the allowlist + only the bundle fields that differ from + # defaults, so the rerun string is both valid Python and minimal. + parts = [f"labels_key={labels_key!r}"] + parts.extend(f"{k}={v!r}" for k, v in prev_params.items() if k in _STITCH_PARAM_KEYS) + nested = prev_params.get("stitch_params") + if isinstance(nested, dict) and nested: + defaults = asdict(StitchParams()) + diff = {k: v for k, v in nested.items() if k in defaults and defaults[k] != v} + if diff: + parts.append(f"stitch_params={diff!r}") + rerun = f"sq.experimental.tl.assign_stitch_groups(sdata, {', '.join(parts)})" + logg.warning( + f"Re-running calculate_tiling_qc dropped previous stitch columns " + f"({', '.join(present)}) from sdata.tables[{table_key!r}]. " + f"To restore them, run: {rerun}" + ) diff --git a/src/squidpy/experimental/tl/_tiling_stitch.py b/src/squidpy/experimental/tl/_tiling_stitch.py new file mode 100644 index 000000000..52f291b2f --- /dev/null +++ b/src/squidpy/experimental/tl/_tiling_stitch.py @@ -0,0 +1,1022 @@ +"""Stitching of tile-cut cells flagged by :func:`~squidpy.experimental.tl.calculate_tiling_qc`. + +When segmentation is run tile-by-tile (Cellpose, Stardist, Mesmer, ...) cells +that straddle tile boundaries get cut into 2-4 pieces with characteristic +straight, axis-aligned cut edges. :func:`~squidpy.experimental.tl.calculate_tiling_qc` flags these +as ``is_outlier=True``. This module pairs facing cut edges across boundaries +and assigns each candidate pair a heuristic geometric score in [0, 1]. + +The score is a weighted mean of five dataset-independent geometric features -- +``iou``, ``endpoint_match``, ``merge_compactness``, ``merge_solidity`` and +``gap_proximity`` -- computed from the cut-edge geometry and the union mask +after closing the seam gap. No model is fitted or shipped: the weights +default to flat-equal and are user-tunable via ``StitchParams.feature_weights``; +the features actually used, the weights applied, and the formula are recorded +in ``.uns["tiling_stitch"]``. Users should tune ``min_confidence`` for their +data; ``0.7`` is a reasonable starting point, not a calibrated probability. + +The labels element is **never** modified here -- only ``.obs`` columns are +written. Materialising a stitched labels element is opt-in via +:func:`squidpy.experimental.im.make_stitched_labels`. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import asdict, dataclass, field +from typing import TYPE_CHECKING, Any + +import numpy as np +import spatialdata as sd +import xarray as xr +from scipy.ndimage import binary_closing +from scipy.sparse import csr_matrix +from scipy.sparse.csgraph import connected_components +from skimage.measure import label as cc_label +from skimage.measure import regionprops +from skimage.morphology import disk as morph_disk +from spatialdata._logging import logger as logg + +from squidpy.experimental.utils._geometry import equivalent_diameter, largest_contour +from squidpy.experimental.utils._labels import iter_chunked_regionprops, resolve_labels_array +from squidpy.experimental.utils._params import resolve_params + +if TYPE_CHECKING: + from collections.abc import Iterable + + import anndata as ad + +__all__ = ["StitchParams", "assign_stitch_groups"] + +# The scored geometric features and their formula. Defined before StitchParams +# so __post_init__ can validate feature_weights keys against this tuple. +_SCORE_FEATURES: tuple[str, ...] = ("iou", "endpoint_match", "merge_compactness", "merge_solidity", "gap_proximity") + + +@dataclass(slots=True) +class StitchParams: + """Advanced tuning knobs for :func:`~squidpy.experimental.tl.assign_stitch_groups`. + + Defaults work for typical 2D segmentation tiles produced by + cellpose-like pipelines. Pass an instance (or a ``Mapping`` of + field names to values) as ``stitch_params`` to override. Most fields + are *advanced* -- the defaults rarely need changing; ``feature_weights`` + is the main knob a user might reach for. + """ + + distance_tol: float = 0.75 + """Advanced: sub-pixel tolerance for "lies on a bbox edge".""" + + min_edge_length: float = 5.0 + """Advanced: absolute floor on cut-edge length (pixels).""" + + min_edge_length_ratio: float = 0.4 + """Advanced: minimum cut-edge length relative to the cell's equivalent diameter.""" + + min_edge_coverage: float = 0.5 + """Advanced: minimum fraction of parallel-axis positions covered by near-edge contour points.""" + + candidate_min_iou: float = 0.2 + """Advanced: loose 1-D IoU floor at candidate enumeration.""" + + close_radius: int = 3 + """Advanced: morphological closing disk radius for the union mask. Also the + length scale for ``gap_proximity`` (normalised by ``2 * close_radius``).""" + + feature_weights: Mapping[str, float] | None = None + """Per-feature weights for the score, keyed by names in :data:`_SCORE_FEATURES`. + + ``None`` (default) means flat-equal weights. A partial mapping is allowed: + unspecified features keep weight ``1.0``. Weights must be non-negative and + are renormalised to sum to 1, so ``stitch_confidence`` stays in [0, 1].""" + + def __post_init__(self) -> None: + # Coerce numeric types (accept numpy scalars cleanly) and bounds-check. + self.distance_tol = float(self.distance_tol) + self.min_edge_length = float(self.min_edge_length) + self.min_edge_length_ratio = float(self.min_edge_length_ratio) + self.min_edge_coverage = float(self.min_edge_coverage) + self.candidate_min_iou = float(self.candidate_min_iou) + self.close_radius = int(self.close_radius) + if self.distance_tol < 0: + raise ValueError(f"distance_tol must be >= 0, got {self.distance_tol}.") + if self.min_edge_length < 0: + raise ValueError(f"min_edge_length must be >= 0, got {self.min_edge_length}.") + if not 0.0 <= self.min_edge_length_ratio <= 1.0: + raise ValueError(f"min_edge_length_ratio must be in [0, 1], got {self.min_edge_length_ratio}.") + if not 0.0 <= self.min_edge_coverage <= 1.0: + raise ValueError(f"min_edge_coverage must be in [0, 1], got {self.min_edge_coverage}.") + if not 0.0 <= self.candidate_min_iou <= 1.0: + raise ValueError(f"candidate_min_iou must be in [0, 1], got {self.candidate_min_iou}.") + if self.close_radius < 0: + raise ValueError(f"close_radius must be >= 0, got {self.close_radius}.") + if self.feature_weights is not None: + if not isinstance(self.feature_weights, Mapping): + raise TypeError( + f"feature_weights must be a Mapping or None, got {type(self.feature_weights).__name__}." + ) + unknown = set(self.feature_weights) - set(_SCORE_FEATURES) + if unknown: + raise ValueError( + f"Unknown feature_weights key(s): {sorted(unknown)}; expected from {list(_SCORE_FEATURES)}." + ) + coerced = {} + for k, v in self.feature_weights.items(): + fv = float(v) + if fv < 0: + raise ValueError(f"feature_weights[{k!r}] must be >= 0, got {fv}.") + coerced[k] = fv + # Store a plain dict of floats (drops numpy scalars, deterministic order). + self.feature_weights = {k: coerced[k] for k in _SCORE_FEATURES if k in coerced} + + +def _resolve_stitch_params(stitch_params: StitchParams | Mapping[str, Any] | None) -> StitchParams: + """Normalise the ``stitch_params`` argument to a :class:`StitchParams` instance.""" + return resolve_params(stitch_params, StitchParams, label="stitch_params") + + +def _resolve_feature_weights(feature_weights: Mapping[str, float] | None) -> dict[str, float]: + """Return a full ``{feature: weight}`` dict over :data:`_SCORE_FEATURES`, renormalised to sum 1. + + ``None`` -> flat-equal. A partial mapping fills unspecified features with + weight ``1.0`` before renormalising. Validation (unknown keys, negatives) + happens in :meth:`StitchParams.__post_init__`; this helper assumes clean input. + """ + base = dict.fromkeys(_SCORE_FEATURES, 1.0) + if feature_weights: + base.update(feature_weights) + total = sum(base.values()) + if total <= 0: + raise ValueError("feature_weights must have a positive sum (at least one feature with weight > 0).") + return {f: base[f] / total for f in _SCORE_FEATURES} + + +_METHOD_KEY = "tiling_stitch" +_STITCH_DEFAULTS = StitchParams() + +# Contract between calculate_tiling_qc and assign_stitch_groups. _STITCH_COLUMNS +# is the obs columns stitch writes back into the QC table; _STITCH_PARAM_KEYS +# is the subset of top-level kwargs valid for re-running assign_stitch_groups +# (the advanced tuning lives in a nested ``stitch_params`` dict). +_STITCH_COLUMNS = ("stitch_group_id", "is_stitched", "n_pieces", "stitch_confidence") +_STITCH_PARAM_KEYS = frozenset({"min_confidence", "max_gap", "max_group_size"}) + + +def _score_formula_str(weights: dict[str, float]) -> str: + """Human-readable, reproducible score formula reflecting the applied weights.""" + terms = " + ".join(f"{weights[f]:.4g}*{f}" for f in _SCORE_FEATURES) + return f"weighted_mean = {terms}" + + +# --------------------------------------------------------------------------- +# Dataclasses +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class _CutEdge: + """A candidate cut edge on a single cell's bbox. + + Attributes + ---------- + cell_id + Label ID of the piece carrying this edge. + axis + ``"h"`` (horizontal cut: edge is a horizontal line, cell sits above + or below it) or ``"v"`` (vertical cut). + coord + Position of the cut line: y-coord for ``"h"``, x-coord for ``"v"``. + extent + ``(min, max)`` along the parallel axis -- the chord at the cut line. + normal_dir + ``+1`` if the cell's centroid sits at greater coord than the cut + line, ``-1`` otherwise. Used to enforce facing pairs. + length + Euclidean length of the run (``extent[1] - extent[0]``). + """ + + cell_id: int + axis: str + coord: float + extent: tuple[float, float] + normal_dir: int + length: float + + +@dataclass(frozen=True) +class _StitchPair: + """A scored candidate pairing of two cut edges across a tile boundary. + + ``confidence`` is the weighted mean of the geometric features (see + :data:`_SCORE_FEATURES`); the individual feature components are kept for + diagnostics and for the ``min``-based group-confidence aggregation. + """ + + cell_a: int + cell_b: int + axis: str + confidence: float + iou: float + endpoint_match: float + gap_proximity: float + merge_solidity: float + merge_compactness: float + edge_a: _CutEdge | None = field(default=None, repr=False) + edge_b: _CutEdge | None = field(default=None, repr=False) + + +# --------------------------------------------------------------------------- +# Stage 1: cut-edge extraction +# --------------------------------------------------------------------------- + + +def _read_bbox_slice(labels_da: xr.DataArray | np.ndarray, y0: int, y1: int, x0: int, x1: int) -> np.ndarray: + """Read a 2-D bbox slice from numpy or xarray, squeezing singleton dims.""" + if isinstance(labels_da, np.ndarray): + return labels_da[y0:y1, x0:x1] + arr = labels_da.isel(y=slice(y0, y1), x=slice(x0, x1)).values + while arr.ndim > 2: + arr = arr.squeeze(0) + return arr + + +def _compute_outlier_bboxes( + labels_da: xr.DataArray | np.ndarray, + outlier_ids: Iterable[int], + chunk_size: int = 4096, +) -> dict[int, tuple[int, int, int, int]]: + """Compute global bboxes for the outlier subset in a single chunked pass. + + Returns mapping ``label_id -> (min_row, min_col, max_row, max_col)``. + Works on numpy or dask-backed xarray; for xarray the array is read in + ``chunk_size`` x ``chunk_size`` tiles so memory is bounded. + """ + outlier_set = {int(x) for x in outlier_ids} + bboxes: dict[int, tuple[int, int, int, int]] = {} + # Single chunked pass (shared with the QC reader); only outlier labels are + # accumulated, merging bboxes across chunk boundaries for cells that span them. + # TODO: faster path -- pre-mask each chunk with np.where(np.isin(chunk, + # outlier_set), chunk, 0) before regionprops, so non-outlier cells are + # skipped instead of scanned. Worth doing if outlier fraction is < ~5%. + for lid, region, y0, x0 in iter_chunked_regionprops(labels_da, chunk_size=chunk_size, label_subset=outlier_set): + r0, c0, r1, c1 = region.bbox + r0 += y0 + c0 += x0 + r1 += y0 + c1 += x0 + prev = bboxes.get(lid) + if prev is None: + bboxes[lid] = (r0, c0, r1, c1) + else: + bboxes[lid] = (min(prev[0], r0), min(prev[1], c0), max(prev[2], r1), max(prev[3], c1)) + return bboxes + + +def _bbox_edge_run( + contour: np.ndarray, + perp_axis: int, + target: float, + distance_tol: float = _STITCH_DEFAULTS.distance_tol, + min_coverage: float = _STITCH_DEFAULTS.min_edge_coverage, +) -> tuple[float, float, float] | None: + """Find the extent of contour points lying near a single bbox edge. + + A genuine cut edge has many contour points clustered at the bbox boundary, + spanning a long parallel-axis range with high integer-position coverage. + A naturally curved cell only touches its bbox at a single point, which + fails either the count, length, or coverage check. + + Returns ``(ext_lo, ext_hi, length)`` if a substantial run is found. + """ + parallel_axis = 1 - perp_axis + near = np.abs(contour[:, perp_axis] - target) <= distance_tol + if near.sum() < 3: + return None + parallel_vals = contour[near, parallel_axis] + ext_lo = float(parallel_vals.min()) + ext_hi = float(parallel_vals.max()) + length = ext_hi - ext_lo + if length <= 0: + return None + width = max(int(np.ceil(length)), 1) + bins = np.zeros(width + 1, dtype=bool) + bins[np.clip((parallel_vals - ext_lo).astype(int), 0, width)] = True + coverage = float(bins.sum()) / (width + 1) + if coverage < min_coverage: + return None + return ext_lo, ext_hi, length + + +def _extract_cut_edges( + labels_da: xr.DataArray | np.ndarray, + outlier_ids: Iterable[int], + bboxes: dict[int, tuple[int, int, int, int]] | None = None, + distance_tol: float = _STITCH_DEFAULTS.distance_tol, + min_edge_length: float = _STITCH_DEFAULTS.min_edge_length, + min_edge_length_ratio: float = _STITCH_DEFAULTS.min_edge_length_ratio, + min_edge_coverage: float = _STITCH_DEFAULTS.min_edge_coverage, +) -> list[_CutEdge]: + """Extract cardinal-aligned bbox-edge runs (cut-edge candidates) per outlier. + + For each outlier cell: + 1. Crop labels to its bbox + 1 px pad, build a binary mask. + 2. Trace its contour with :func:`skimage.measure.find_contours`. + 3. Check each of the 4 bbox-edge lines for a substantial straight run. + + A piece cut at a tile boundary always has its cut on a bbox edge -- the + piece terminates exactly at the cut. Curved cells only touch the bbox + at a single contour point, which the density check rejects. + + Cells at a 4-tile corner produce 2 perpendicular edges; mid-stripe pieces + can produce 2 parallel edges. + """ + outlier_list = [int(x) for x in outlier_ids] + if bboxes is None: + bboxes = _compute_outlier_bboxes(labels_da, outlier_list) + + edges: list[_CutEdge] = [] + for lid in outlier_list: + bbox = bboxes.get(lid) + if bbox is None: + continue + min_r, min_c, max_r, max_c = bbox + + crop_arr = _read_bbox_slice(labels_da, min_r, max_r, min_c, max_c) + mask = (crop_arr == lid).astype(np.float32) + if not mask.any(): + continue + mask = np.pad(mask, 1, mode="constant", constant_values=0) + contour = largest_contour(mask) + if contour is None: + continue + contour_global = contour.copy() + contour_global[:, 0] += min_r - 1 + contour_global[:, 1] += min_c - 1 + + # Local centroid from the mask (avoids a second regionprops call). + ys, xs = np.where(mask) + cy = float(ys.mean()) + min_r - 1 + cx = float(xs.mean()) + min_c - 1 + area = float(mask.sum()) + eq_diameter = equivalent_diameter(area) + min_len = max(min_edge_length, min_edge_length_ratio * eq_diameter) + + # find_contours places level set 0.5 outside the integer pixel boundary. + bbox_targets = [ + ("h", float(min_r) - 0.5), + ("h", float(max_r) - 0.5), + ("v", float(min_c) - 0.5), + ("v", float(max_c) - 0.5), + ] + for axis, target in bbox_targets: + perp_axis = 0 if axis == "h" else 1 + run = _bbox_edge_run(contour_global, perp_axis, target, distance_tol, min_edge_coverage) + if run is None: + continue + ext_lo, ext_hi, length = run + if length < min_len: + continue + cell_coord = cy if axis == "h" else cx + normal = 1 if cell_coord > target else -1 + edges.append( + _CutEdge( + cell_id=lid, + axis=axis, + coord=target, + extent=(ext_lo, ext_hi), + normal_dir=normal, + length=float(length), + ) + ) + + return edges + + +# --------------------------------------------------------------------------- +# Stage 2: pair candidate enumeration + features +# --------------------------------------------------------------------------- + + +def _extent_overlap(a: tuple[float, float], b: tuple[float, float]) -> float: + return max(0.0, min(a[1], b[1]) - max(a[0], b[0])) + + +def _merge_shape_features( + labels_da: xr.DataArray | np.ndarray, + cell_ids: Iterable[int], + bboxes: dict[int, tuple[int, int, int, int]], + close_radius: int = _STITCH_DEFAULTS.close_radius, +) -> dict[str, float]: + """Materialise the union of given pieces, close the gap, and return shape stats. + + Solidity (area / convex_hull_area) and compactness (4*pi*A / P^2) drop + sharply when two unrelated cells are joined -- the union is concave at the + join. ``merge_compactness`` is typically the strongest single + discriminator between true cuts and false merges. + """ + cell_list = [int(c) for c in cell_ids] + if not cell_list: + return {"merge_solidity": 0.0, "merge_compactness": 0.0} + + # Union bbox + padding to give morphological closing room. + rs = [bboxes[c][0] for c in cell_list if c in bboxes] + cs = [bboxes[c][1] for c in cell_list if c in bboxes] + re = [bboxes[c][2] for c in cell_list if c in bboxes] + ce = [bboxes[c][3] for c in cell_list if c in bboxes] + if not rs: + return {"merge_solidity": 0.0, "merge_compactness": 0.0} + pad = close_radius + 2 + H = labels_da.shape[-2] if hasattr(labels_da, "shape") else int(labels_da.sizes["y"]) + W = labels_da.shape[-1] if hasattr(labels_da, "shape") else int(labels_da.sizes["x"]) + r0 = max(min(rs) - pad, 0) + c0 = max(min(cs) - pad, 0) + r1 = min(max(re) + pad, H) + c1 = min(max(ce) + pad, W) + + crop = _read_bbox_slice(labels_da, r0, r1, c0, c1) + mask = np.isin(crop, cell_list) + if not mask.any(): + return {"merge_solidity": 0.0, "merge_compactness": 0.0} + + closed = binary_closing(mask, structure=morph_disk(close_radius)) + cc = cc_label(closed, connectivity=2) + if cc.max() == 0: + return {"merge_solidity": 0.0, "merge_compactness": 0.0} + sizes = np.bincount(cc.ravel()) + sizes[0] = 0 + biggest = int(sizes.argmax()) + region = regionprops((cc == biggest).astype(np.uint8))[0] + perimeter = max(region.perimeter, 1.0) + compactness = float(min(4 * np.pi * region.area / (perimeter * perimeter), 1.0)) + # Clamp solidity to 1.0: skimage can return area/convex_area slightly >1 for + # thin/degenerate rasterised regions, which would push the score out of [0, 1]. + solidity = float(min(region.solidity, 1.0)) + return {"merge_solidity": solidity, "merge_compactness": compactness} + + +def _pair_geometry_features( + e: _CutEdge, + c: _CutEdge, + max_gap: float, + candidate_min_iou: float = _STITCH_DEFAULTS.candidate_min_iou, +) -> dict[str, float] | None: + """Compute geometry-only features for a candidate pair, returning ``None`` + if the pair fails the basic facing/overlap/IoU filters. + """ + if c.normal_dir == e.normal_dir: + return None + # Facing: cell with +1 normal must sit at greater coord than cell with -1. + if (e.coord - c.coord) * e.normal_dir < -1e-6: + return None + overlap = _extent_overlap(e.extent, c.extent) + if overlap <= 0: + return None + union = e.length + c.length - overlap + iou = overlap / union if union > 0 else 0.0 + if iou < candidate_min_iou: + return None + gap = abs(e.coord - c.coord) + if gap > max_gap: + return None + endpoint_dist = abs(e.extent[0] - c.extent[0]) + abs(e.extent[1] - c.extent[1]) + max_len = max(e.length, c.length) + endpoint_match = max(0.0, 1.0 - endpoint_dist / max_len) if max_len > 0 else 0.0 + # Return the raw perpendicular gap; gap_proximity is derived later against + # the closing reach (2*close_radius), NOT against max_gap (a search radius). + return { + "iou": float(iou), + "endpoint_match": float(endpoint_match), + "gap": float(gap), + } + + +def _enumerate_pair_candidates( + edges: list[_CutEdge], + max_gap: float, + candidate_min_iou: float = _STITCH_DEFAULTS.candidate_min_iou, +) -> list[tuple[_CutEdge, _CutEdge, dict[str, float]]]: + """Find all (e, c) pairs of facing cut edges with their geometry features. + + Returns one entry per surviving candidate. No selection / scoring yet. + """ + out: list[tuple[_CutEdge, _CutEdge, dict[str, float]]] = [] + by_axis: dict[str, list[_CutEdge]] = {"h": [], "v": []} + for e in edges: + by_axis[e.axis].append(e) + + for axis_edges in by_axis.values(): + axis_edges.sort(key=lambda e: e.coord) + coords = np.array([e.coord for e in axis_edges]) + for i, e in enumerate(axis_edges): + lo = int(np.searchsorted(coords, e.coord - max_gap, side="left")) + hi = int(np.searchsorted(coords, e.coord + max_gap, side="right")) + for j in range(lo, hi): + if j <= i: + continue # symmetry: emit each unordered pair once + c = axis_edges[j] + if c.cell_id == e.cell_id: + continue + feats = _pair_geometry_features(e, c, max_gap, candidate_min_iou=candidate_min_iou) + if feats is None: + continue + out.append((e, c, feats)) + return out + + +# --------------------------------------------------------------------------- +# Stage 4: scoring (weighted mean of geometry + shape features) +# --------------------------------------------------------------------------- + + +def _gap_proximity(gap: float, close_radius: int) -> float: + """Map the raw perpendicular gap to [0, 1] against the closing reach. + + Normalised by ``2 * close_radius`` -- the scale at which morphological + closing could actually bridge the seam -- so the feature is independent of + the ``max_gap`` search radius and only reaches 0 when the gap genuinely + exceeds what closing can join. When closing is disabled (``close_radius=0``) + the feature is inactive and returns ``1.0`` rather than collapsing the score. + """ + reach = 2 * close_radius + # gap<=0 (touching/overlapping) or reach<=0 (closing disabled, close_radius=0) + # -> the feature is inactive (neutral 1.0), never a silent score cliff. + if gap <= 0 or reach <= 0: + return 1.0 + return max(0.0, 1.0 - gap / reach) + + +def _score_pair_features(features: dict[str, float], weights: dict[str, float]) -> float: + """Return the heuristic stitch score in [0, 1]. + + Weighted mean of the features in :data:`_SCORE_FEATURES` (``weights`` are + pre-normalised to sum 1). The score is dataset-independent and not a + calibrated probability -- users pick ``min_confidence`` based on their + false-merge tolerance. + """ + return float(sum(weights[name] * features[name] for name in _SCORE_FEATURES)) + + +def _score_pairs( + candidates: list[tuple[_CutEdge, _CutEdge, dict[str, float]]], + labels_da: xr.DataArray | np.ndarray, + bboxes: dict[int, tuple[int, int, int, int]], + weights: dict[str, float], + close_radius: int = _STITCH_DEFAULTS.close_radius, +) -> list[_StitchPair]: + """Compute shape features per candidate and score every pair (no filtering). + + Returns all scored pairs (one per ``(cell_a, cell_b, axis)``, keeping max + confidence on duplicates); the ``min_confidence`` cut is applied by the + caller so diagnostics can also see below-threshold pairs. + """ + scored: list[_StitchPair] = [] + for e, c, geom in candidates: + shape = _merge_shape_features(labels_da, [e.cell_id, c.cell_id], bboxes, close_radius=close_radius) + feats = {**geom, **shape, "gap_proximity": _gap_proximity(geom["gap"], close_radius)} + confidence = _score_pair_features(feats, weights) + # Canonicalise so cell_a < cell_b for deterministic union-find. + if e.cell_id < c.cell_id: + ea, eb = e, c + else: + ea, eb = c, e + scored.append( + _StitchPair( + cell_a=ea.cell_id, + cell_b=eb.cell_id, + axis=e.axis, + confidence=confidence, + iou=feats["iou"], + endpoint_match=feats["endpoint_match"], + gap_proximity=feats["gap_proximity"], + merge_solidity=feats["merge_solidity"], + merge_compactness=feats["merge_compactness"], + edge_a=ea, + edge_b=eb, + ) + ) + + # Deduplicate to one entry per (cell_a, cell_b, axis), keeping max confidence. + by_pair: dict[tuple[int, int, str], _StitchPair] = {} + for p in scored: + k = (p.cell_a, p.cell_b, p.axis) + if k not in by_pair or by_pair[k].confidence < p.confidence: + by_pair[k] = p + return sorted(by_pair.values(), key=lambda p: (-p.confidence, p.cell_a, p.cell_b)) + + +# --------------------------------------------------------------------------- +# Stage 5: group assembly via union-find + validation +# --------------------------------------------------------------------------- + + +def _validate_group_geometry( + pairs_in_group: list[_StitchPair], + size: int, + max_gap: float, +) -> bool: + """Geometric sanity check for groups of size >= 3. + + Two cases: + + - **Corner group** (size 4, both axes present): the cut edges' endpoints + must converge near a single junction point (one ``h`` cut crossing one + ``v`` cut defines the junction). If the spread of edge extents from + the junction is greater than ``max_gap``, the group is implausible. + + - **Chain group** (size 3 or 4, all pairs share one axis): legitimate + same-axis chains (e.g., a cell split by 3 horizontal seams into 4 + vertically-stacked pieces) have pairs at N-1 *distinct* seam + coordinates. Multiple pairs at the same seam coord would imply + geometrically impossible "two cuts at the same seam" pairings -- a + signature of a false-positive cluster -- so we reject. + """ + h_pairs = [p for p in pairs_in_group if p.axis == "h"] + v_pairs = [p for p in pairs_in_group if p.axis == "v"] + + # Chain case: only one axis present and size >= 3. + if not h_pairs or not v_pairs: + if size < 3: + return True # 2-piece groups are trivially valid on one axis + # Each pair's seam coord is roughly midway between its two edges. + seam_coords = [round((p.edge_a.coord + p.edge_b.coord) / 2.0, 1) for p in pairs_in_group] + # Allow a max_gap-sized tolerance for "distinct" seams. + sorted_coords = sorted(seam_coords) + for prev, cur in zip(sorted_coords, sorted_coords[1:], strict=False): + if cur - prev <= max_gap: + return False + return True + + # Mixed-axis case: only validate the 4-piece corner pattern. 3-piece + # L-shapes (one h pair + one v pair sharing a corner cell) are + # geometrically valid and don't have a junction to converge on. + if size != 4: + return True + + # Corner case: both axes present, size 4. Junction y/x is the mean of edge coords. + h_edges = [p.edge_a for p in h_pairs] + [p.edge_b for p in h_pairs] + v_edges = [p.edge_a for p in v_pairs] + [p.edge_b for p in v_pairs] + junction_y = float(np.mean([e.coord for e in h_edges])) + junction_x = float(np.mean([e.coord for e in v_edges])) + for e in h_edges: + if min(abs(e.extent[0] - junction_x), abs(e.extent[1] - junction_x)) > max_gap: + return False + for e in v_edges: + if min(abs(e.extent[0] - junction_y), abs(e.extent[1] - junction_y)) > max_gap: + return False + return True + + +def _assemble_groups( + pairs: list[_StitchPair], + candidate_ids: Iterable[int], + max_group_size: int, + max_gap: float, +) -> tuple[dict[int, int], dict[int, float]]: + """Build stitch groups via union-find with size + corner validation. + + Returns + ------- + groups + ``cell_id -> group_id`` (group_id == own cell_id for unstitched). + confidences + ``cell_id -> stitch_confidence`` -- min over pairwise confidences in + the cell's group; ``1.0`` for confirmed-solo (no surviving pair). + """ + # Build undirected connected components via scipy. Cells map to a + # contiguous [0, n) index space; pairs become symmetric edges in a CSR + # adjacency matrix. We then re-key components by the smallest cell_id + # they contain so the group root is deterministic. + candidate_list = sorted({int(c) for c in candidate_ids}) + if not candidate_list: + return {}, {} + id_to_idx = {cid: i for i, cid in enumerate(candidate_list)} + n = len(candidate_list) + + valid_pairs = [p for p in pairs if p.cell_a in id_to_idx and p.cell_b in id_to_idx] + if valid_pairs: + rows = [id_to_idx[p.cell_a] for p in valid_pairs] + cols = [id_to_idx[p.cell_b] for p in valid_pairs] + adj = csr_matrix((np.ones(len(rows), dtype=np.int8), (rows, cols)), shape=(n, n)) + _, comp_labels = connected_components(adj, directed=False) + else: + comp_labels = np.arange(n) + + cells_by_comp: dict[int, list[int]] = {} + for i, comp in enumerate(comp_labels): + cells_by_comp.setdefault(int(comp), []).append(candidate_list[i]) + + members: dict[int, list[int]] = {} + root_of_cell: dict[int, int] = {} + for comp_members in cells_by_comp.values(): + comp_members.sort() + root = comp_members[0] + members[root] = comp_members + for cid in comp_members: + root_of_cell[cid] = root + + pairs_by_group: dict[int, list[_StitchPair]] = {} + for p in valid_pairs: + pairs_by_group.setdefault(root_of_cell[p.cell_a], []).append(p) + + groups: dict[int, int] = {} + confidences: dict[int, float] = {} + + for root, mem in members.items(): + size = len(mem) + group_pairs = pairs_by_group.get(root, []) + + # Size cap: collapse oversized groups back to singletons. + if size > max_group_size: + for m in mem: + groups[m] = m + confidences[m] = 1.0 + continue + + # Geometric validation for 3+ piece groups: corner-junction for + # mixed-axis 4-groups, chain (distinct seam coords) for same-axis 3+. + if size >= 3 and not _validate_group_geometry(group_pairs, size, max_gap): + for m in mem: + groups[m] = m + confidences[m] = 1.0 + continue + + if size == 1: + groups[mem[0]] = mem[0] + confidences[mem[0]] = 1.0 + continue + + # Group confidence = min over pairwise confidences (weakest link). + group_conf = float(min(p.confidence for p in group_pairs)) + for m in mem: + groups[m] = root + confidences[m] = group_conf + + return groups, confidences + + +def _build_diagnostics( + all_pairs: list[_StitchPair], + groups: dict[int, int], + group_sizes: dict[int, int], + min_confidence: float, +) -> dict[str, np.ndarray]: + """Per-pair diagnostics for ``save_diagnostics``, as a zarr-safe dict of arrays. + + One entry per scored candidate (including below-threshold ones), with each + feature, the confidence, the assigned ``group_id``, and a ``status``: + + - ``"accepted"`` -- passed the confidence cut and landed in a multi-piece group; + - ``"below_threshold"`` -- confidence < ``min_confidence``; + - ``"collapsed_group"`` -- passed the cut but its group was collapsed to a + singleton by geometry validation or the size cap. + + Returned as a ``dict`` of equal-length :class:`numpy.ndarray` (rather than a + DataFrame) so it round-trips cleanly through zarr/h5ad-backed ``.uns``. + """ + n = len(all_pairs) + out: dict[str, np.ndarray] = { + "cell_a": np.empty(n, dtype=np.int64), + "cell_b": np.empty(n, dtype=np.int64), + "axis": np.empty(n, dtype=" 1: + status = "accepted" + else: + status = "collapsed_group" + out["cell_a"][i] = int(p.cell_a) + out["cell_b"][i] = int(p.cell_b) + out["axis"][i] = p.axis + out["iou"][i] = float(p.iou) + out["endpoint_match"][i] = float(p.endpoint_match) + out["merge_compactness"][i] = float(p.merge_compactness) + out["merge_solidity"][i] = float(p.merge_solidity) + out["gap_proximity"][i] = float(p.gap_proximity) + out["confidence"][i] = float(p.confidence) + out["group_id"][i] = int(root) + out["status"][i] = status + return out + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + + +def assign_stitch_groups( + sdata: sd.SpatialData, + labels_key: str, + qc_table_key: str | None = None, + min_confidence: float = 0.7, + max_gap: float = 3.0, + max_group_size: int = 4, + stitch_params: StitchParams | Mapping[str, Any] | None = None, + save_diagnostics: bool = False, + inplace: bool = True, +) -> ad.AnnData | None: + """Assign tile-cut cell pieces to stitch groups. + + Reads ``is_outlier=True`` cells flagged by + :func:`~squidpy.experimental.tl.calculate_tiling_qc`, pairs facing cut + edges across tile boundaries, scores each pair via a transparent geometric + composite, and assembles high-confidence pairs into stitch groups via + union-find. This only *annotates* which pieces belong together -- it does + **not** modify the labels element. Materialising a stitched labels element + is opt-in via :func:`squidpy.experimental.im.make_stitched_labels`. + + The score per pair is a weighted mean of five geometric features in [0, 1]: + ``iou`` (1-D extent overlap), ``endpoint_match`` (chord endpoints coincide), + ``merge_compactness`` (``4*pi*A / P^2`` of the closed union mask), + ``merge_solidity`` (union area / convex hull area), and ``gap_proximity`` + (seam gap relative to the morphological closing reach). Weights default to + flat-equal and are tunable via ``StitchParams.feature_weights``. No + coefficients are fitted or shipped; the features, weights, and formula are + recorded in ``.uns["tiling_stitch"]`` so a run is re-derivable from its own + metadata. + + Parameters + ---------- + sdata + :class:`~spatialdata.SpatialData` with a labels element and a QC + table from :func:`~squidpy.experimental.tl.calculate_tiling_qc`. + labels_key + Key in ``sdata.labels``. + qc_table_key + Key of the QC table. Defaults to ``"{labels_key}_qc"``. + min_confidence + Threshold on ``stitch_confidence``. ``0.7`` (default) is a starting + point; raise it for stricter precision, lower for recall. Tune for + your data -- the score is heuristic, not a calibrated probability. + max_gap + Maximum perpendicular distance (px) between facing cut edges for a pair + to be *considered* a candidate. This is a search radius only; it does + not scale the score. + max_group_size + Cap on group size; oversized groups (likely false merges) collapse + to singletons. + stitch_params + Advanced tuning knobs as a :class:`StitchParams` instance or a + ``Mapping`` of its field names to values. See :class:`StitchParams` + for each field's meaning and default. ``None`` (default) uses + all defaults. + save_diagnostics + If ``True``, write a per-pair diagnostics table (every scored candidate: + its feature values, confidence, assigned ``group_id``, and a ``status`` of + ``"accepted"`` / ``"below_threshold"`` / ``"collapsed_group"``) to + ``.uns["tiling_stitch"]["diagnostics"]`` as a dict of equal-length arrays. + Useful for tuning ``min_confidence``; off by default to keep ``.uns`` lean. + inplace + If ``True``, write back into ``sdata.tables[qc_table_key]``. + Otherwise return the modified AnnData. + + Returns + ------- + The QC :class:`~anndata.AnnData` with four new ``.obs`` columns when + ``inplace=False``, otherwise ``None``. + """ + if labels_key not in sdata.labels: + raise ValueError(f"Labels key '{labels_key}' not found in sdata.labels.") + if min_confidence < 0 or min_confidence > 1: + raise ValueError(f"min_confidence must be in [0, 1], got {min_confidence}.") + if max_gap < 0: + raise ValueError(f"max_gap must be non-negative, got {max_gap}.") + if max_group_size < 1: + raise ValueError(f"max_group_size must be >= 1, got {max_group_size}.") + params = _resolve_stitch_params(stitch_params) + weights = _resolve_feature_weights(params.feature_weights) + + table_key = qc_table_key if qc_table_key is not None else f"{labels_key}_qc" + if table_key not in sdata.tables: + raise ValueError(f"QC table '{table_key}' not found. Run calculate_tiling_qc first.") + adata = sdata.tables[table_key].copy() + + if "is_outlier" not in adata.obs.columns: + raise ValueError(f"QC table '{table_key}' is missing 'is_outlier'; re-run calculate_tiling_qc.") + if "label_id" not in adata.obs.columns: + raise ValueError(f"QC table '{table_key}' is missing 'label_id'.") + + existing = [c for c in _STITCH_COLUMNS if c in adata.obs.columns] + if existing: + logg.warning(f"Overwriting existing stitch columns: {existing}.") + adata.obs.drop(columns=existing, inplace=True) + + # Resolve which labels DataArray was used at QC time (multi-scale aware). + qc_params = adata.uns.get("tiling_qc", {}) + scale = qc_params.get("scale") + labels_da = resolve_labels_array(sdata, labels_key, scale) + + label_ids = adata.obs["label_id"].astype(int).to_numpy() + is_outlier = adata.obs["is_outlier"].to_numpy(dtype=bool) + outlier_ids = label_ids[is_outlier].tolist() + + n_outliers = len(outlier_ids) + logg.info(f"Stitching {n_outliers} outlier cells (out of {len(label_ids)} total).") + + if n_outliers == 0: + logg.warning("No outliers flagged; nothing to stitch.") + groups: dict[int, int] = {} + confidences: dict[int, float] = {} + edges: list[_CutEdge] = [] + all_pairs: list[_StitchPair] = [] + pairs: list[_StitchPair] = [] + else: + bboxes = _compute_outlier_bboxes(labels_da, outlier_ids) + missing = [lid for lid in outlier_ids if lid not in bboxes] + if missing: + logg.warning( + f"{len(missing)} outlier label_id(s) flagged in the QC table do not appear " + f"in '{labels_key}' (e.g. {missing[:5]}); they will not be stitched." + ) + edges = _extract_cut_edges( + labels_da, + outlier_ids, + bboxes=bboxes, + distance_tol=params.distance_tol, + min_edge_length=params.min_edge_length, + min_edge_length_ratio=params.min_edge_length_ratio, + min_edge_coverage=params.min_edge_coverage, + ) + candidates = _enumerate_pair_candidates(edges, max_gap=max_gap, candidate_min_iou=params.candidate_min_iou) + # Score every candidate, then apply the confidence cut. Keeping the + # full list lets save_diagnostics expose below-threshold pairs too. + all_pairs = _score_pairs(candidates, labels_da, bboxes, weights=weights, close_radius=params.close_radius) + pairs = [p for p in all_pairs if p.confidence >= min_confidence] + groups, confidences = _assemble_groups(pairs, outlier_ids, max_group_size=max_group_size, max_gap=max_gap) + + # True candidate count (pre-threshold) for the audit block; then release the + # below-threshold pairs unless diagnostics needs them. + n_candidates = len(all_pairs) + if not save_diagnostics: + all_pairs = [] + + # Write .obs columns with three states distinguished by stitch_confidence: + # - non-outlier cell -> own label_id, False, 1, NaN (not evaluated) + # - outlier solo -> own label_id, False, 1, 1.0 (checked, no partner) + # - outlier stitched -> shared root, True, n, composite score + n = len(label_ids) + stitch_group_id = label_ids.copy() + is_stitched = np.zeros(n, dtype=bool) + n_pieces = np.ones(n, dtype=np.int32) + stitch_confidence = np.full(n, np.nan, dtype=np.float64) + + group_sizes: dict[int, int] = {} + if outlier_ids: + for root in groups.values(): + group_sizes[root] = group_sizes.get(root, 0) + 1 + + id_to_idx = {int(lid): i for i, lid in enumerate(label_ids)} + for cid, root in groups.items(): + i = id_to_idx[int(cid)] + stitch_group_id[i] = int(root) + size = group_sizes[root] + n_pieces[i] = size + is_stitched[i] = size > 1 + stitch_confidence[i] = float(confidences.get(cid, 1.0)) + + adata.obs["stitch_group_id"] = stitch_group_id + adata.obs["is_stitched"] = is_stitched + adata.obs["n_pieces"] = n_pieces + adata.obs["stitch_confidence"] = stitch_confidence + + n_groups = sum(1 for s in group_sizes.values() if s > 1) + n_stitched = int(is_stitched.sum()) + # Use string keys so the dict round-trips through zarr-backed .uns cleanly. + pieces_dist: dict[str, int] = {} + for s in group_sizes.values(): + if s > 1: + key = str(int(s)) + pieces_dist[key] = pieces_dist.get(key, 0) + 1 + + # asdict(params) may carry feature_weights=None; drop it so no None is nested + # in .uns (not reliably zarr-serialisable). The resolved, renormalised weights + # are recorded separately under "feature_weights" for reproducibility. + stitch_params_dump = {k: v for k, v in asdict(params).items() if v is not None} + adata.uns[_METHOD_KEY] = { + "min_confidence": float(min_confidence), + "max_gap": float(max_gap), + "max_group_size": int(max_group_size), + "stitch_params": stitch_params_dump, + "n_outliers": int(n_outliers), + "n_candidate_pairs": int(n_candidates), + "n_stitched_groups": int(n_groups), + "n_stitched_cells": int(n_stitched), + "n_pieces_distribution": pieces_dist, + "score_features": list(_SCORE_FEATURES), + "feature_weights": {k: float(v) for k, v in weights.items()}, + "score_formula": _score_formula_str(weights), + } + + if save_diagnostics: + adata.uns[_METHOD_KEY]["diagnostics"] = _build_diagnostics(all_pairs, groups, group_sizes, min_confidence) + + if not inplace: + return adata + sdata.tables[table_key] = adata + return None diff --git a/src/squidpy/experimental/utils/_geometry.py b/src/squidpy/experimental/utils/_geometry.py new file mode 100644 index 000000000..4f6c152c2 --- /dev/null +++ b/src/squidpy/experimental/utils/_geometry.py @@ -0,0 +1,30 @@ +"""Shared internal geometry helpers for mask/contour analysis. + +Not part of the public API - symbols here are private and may change +without notice. +""" + +from __future__ import annotations + +import numpy as np +from skimage.measure import find_contours + + +def equivalent_diameter(area: float) -> float: + """Diameter of the circle with the given area: ``sqrt(4 * area / pi)``.""" + return float(np.sqrt(4 * area / np.pi)) + + +def largest_contour(padded_mask: np.ndarray, level: float = 0.5) -> np.ndarray | None: + """Return the longest :func:`skimage.measure.find_contours` contour, or ``None``. + + The mask must be **already 1px zero-padded** by the caller so that cells + touching the crop edge (e.g. filling their bbox) are traced closed. Padding + is left to the caller because its placement relative to other steps (e.g. + downsampling) is order-sensitive and differs between call sites. Returned + coordinates are in the padded mask's frame. + """ + contours = find_contours(padded_mask, level) + if not contours: + return None + return max(contours, key=len) diff --git a/src/squidpy/experimental/utils/_labels.py b/src/squidpy/experimental/utils/_labels.py index 5d18e8370..616f64358 100644 --- a/src/squidpy/experimental/utils/_labels.py +++ b/src/squidpy/experimental/utils/_labels.py @@ -6,11 +6,56 @@ from __future__ import annotations +from collections.abc import Iterable, Iterator +from typing import Any + +import numpy as np import spatialdata as sd import xarray as xr +from skimage.measure import regionprops from spatialdata._logging import logger as logg +def iter_chunked_regionprops( + labels: xr.DataArray | np.ndarray, + chunk_size: int = 4096, + label_subset: Iterable[int] | None = None, +) -> Iterator[tuple[int, Any, int, int]]: + """Yield ``(label_id, region, y0, x0)`` over chunked ``regionprops`` of a labels array. + + Works on a plain :class:`numpy.ndarray` (a single chunk) or a possibly + dask-backed 2-D :class:`xarray.DataArray`, reading at most ``chunk_size`` x + ``chunk_size`` at a time so memory stays bounded for very large images. + + ``region`` is a :class:`skimage.measure.RegionProperties` whose coordinates + are LOCAL to the chunk; add ``y0`` / ``x0`` for global coordinates. When + ``label_subset`` is given, only regions with those label ids are yielded. + Background (label 0) is never yielded (``regionprops`` skips it). + """ + subset = None if label_subset is None else {int(x) for x in label_subset} + + if isinstance(labels, np.ndarray): + for region in regionprops(labels): + lid = int(region.label) + if subset is None or lid in subset: + yield lid, region, 0, 0 + return + + h = int(labels.sizes.get("y", labels.shape[-2])) + w = int(labels.sizes.get("x", labels.shape[-1])) + for y0 in range(0, h, chunk_size): + y1 = min(y0 + chunk_size, h) + for x0 in range(0, w, chunk_size): + x1 = min(x0 + chunk_size, w) + chunk = labels.isel(y=slice(y0, y1), x=slice(x0, x1)).values + while chunk.ndim > 2: + chunk = chunk.squeeze(0) + for region in regionprops(chunk): + lid = int(region.label) + if subset is None or lid in subset: + yield lid, region, y0, x0 + + def resolve_labels_array(sdata: sd.SpatialData, labels_key: str, scale: str | None) -> xr.DataArray: """Resolve a labels element to its 2-D ``xarray.DataArray``. diff --git a/src/squidpy/experimental/utils/_params.py b/src/squidpy/experimental/utils/_params.py new file mode 100644 index 000000000..3b7d40f8c --- /dev/null +++ b/src/squidpy/experimental/utils/_params.py @@ -0,0 +1,40 @@ +"""Shared internal helper for resolving params-dataclass arguments. + +Not part of the public API - symbols here are private and may change +without notice. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import fields +from typing import Any, TypeVar + +_T = TypeVar("_T") + + +def resolve_params(value: _T | Mapping[str, Any] | None, cls: type[_T], *, label: str) -> _T: + """Normalise a params argument (``None`` / instance / ``Mapping``) to a ``cls`` instance. + + Parameters + ---------- + value + ``None`` (use defaults), an instance of ``cls`` (passed through by + identity), or a ``Mapping`` of field names to values. + cls + The params dataclass to construct. + label + The user-facing argument name used verbatim in error messages. Include + backticks if the caller's convention uses them (e.g. ``"`tiling_qc_params`"``). + """ + if value is None: + return cls() + if isinstance(value, cls): + return value + if isinstance(value, Mapping): + valid = {f.name for f in fields(cls)} + unknown = set(value) - valid + if unknown: + raise ValueError(f"Unknown {label} field(s): {sorted(unknown)}; expected from {sorted(valid)}.") + return cls(**value) + raise TypeError(f"{label} must be {cls.__name__}, Mapping, or None; got {type(value).__name__}.") diff --git a/tests/experimental/test_tiling_stitch.py b/tests/experimental/test_tiling_stitch.py new file mode 100644 index 000000000..bcb2c53cc --- /dev/null +++ b/tests/experimental/test_tiling_stitch.py @@ -0,0 +1,535 @@ +"""Tests for sq.experimental.tl.assign_stitch_groups.""" + +from __future__ import annotations + +import dask.array as da +import matplotlib.pyplot as plt +import numpy as np +import pytest +import xarray as xr +from spatialdata import SpatialData +from spatialdata.models import Labels2DModel + +import squidpy as sq +from tests.conftest import PlotTester, PlotTesterMeta + + +def _run_qc_and_stitch(sdata, **stitch_kwargs): + """Run QC + stitch on the fixture sdata; return the resulting AnnData.""" + sq.experimental.tl.calculate_tiling_qc( + sdata, + labels_key="labels", + tile_size=200, + nmads_cut=1.0, + nmads_smoothed=1.5, + ) + sq.experimental.tl.assign_stitch_groups(sdata, labels_key="labels", **stitch_kwargs) + return sdata.tables["labels_qc"] + + +# --------------------------------------------------------------------------- +# Smoke + column contract +# --------------------------------------------------------------------------- + + +class TestStitchObsContract: + """The 4 .obs columns and the NaN-vs-1.0 confidence convention.""" + + def test_columns_present(self, sdata_tile_boundary): + sdata, _ = sdata_tile_boundary + adata = _run_qc_and_stitch(sdata) + for col in ("stitch_group_id", "is_stitched", "n_pieces", "stitch_confidence"): + assert col in adata.obs.columns, f"missing {col}" + + def test_non_outliers_have_nan_confidence(self, sdata_tile_boundary): + sdata, _ = sdata_tile_boundary + adata = _run_qc_and_stitch(sdata) + non_outliers = ~adata.obs["is_outlier"].astype(bool) + assert non_outliers.sum() > 0 + assert adata.obs.loc[non_outliers, "stitch_confidence"].isna().all() + assert (adata.obs.loc[non_outliers, "stitch_group_id"] == adata.obs.loc[non_outliers, "label_id"]).all() + assert (adata.obs.loc[non_outliers, "n_pieces"] == 1).all() + assert (~adata.obs.loc[non_outliers, "is_stitched"].astype(bool)).all() + + def test_solo_outliers_have_1p0_confidence(self, sdata_tile_boundary): + sdata, _ = sdata_tile_boundary + adata = _run_qc_and_stitch(sdata) + solo_outliers = adata.obs["is_outlier"].astype(bool) & ~adata.obs["is_stitched"].astype(bool) + if solo_outliers.sum() > 0: + assert (adata.obs.loc[solo_outliers, "stitch_confidence"] == 1.0).all() + + def test_stitched_have_composite_confidence(self, sdata_tile_boundary): + sdata, _ = sdata_tile_boundary + adata = _run_qc_and_stitch(sdata, min_confidence=0.5) + stitched = adata.obs["is_stitched"].astype(bool) + if stitched.sum() > 0: + confs = adata.obs.loc[stitched, "stitch_confidence"] + assert (confs >= 0.5).all() + assert (confs <= 1.0).all() + sizes = adata.obs.loc[stitched, "n_pieces"] + assert (sizes >= 2).all() + assert (sizes <= 4).all() + + def test_group_id_shared_within_group(self, sdata_tile_boundary): + sdata, _ = sdata_tile_boundary + adata = _run_qc_and_stitch(sdata, min_confidence=0.5) + stitched = adata.obs[adata.obs["is_stitched"].astype(bool)] + for gid, members in stitched.groupby("stitch_group_id"): + n = members["n_pieces"].iloc[0] + assert len(members) == n, f"group {gid}: {len(members)} rows but n_pieces={n}" + + +# --------------------------------------------------------------------------- +# Param resolution + feature weights +# --------------------------------------------------------------------------- + + +class TestStitchParamsResolution: + def test_none_uses_defaults(self): + from squidpy.experimental.tl._tiling_stitch import StitchParams, _resolve_stitch_params + + p = _resolve_stitch_params(None) + assert isinstance(p, StitchParams) + assert p.distance_tol == 0.75 + assert p.close_radius == 3 + assert p.feature_weights is None + + def test_instance_passthrough(self): + from squidpy.experimental.tl._tiling_stitch import StitchParams, _resolve_stitch_params + + inst = StitchParams(distance_tol=1.0) + assert _resolve_stitch_params(inst) is inst + + def test_mapping_construction(self): + from squidpy.experimental.tl._tiling_stitch import _resolve_stitch_params + + p = _resolve_stitch_params({"distance_tol": 1.5, "close_radius": 5}) + assert p.distance_tol == 1.5 + assert p.close_radius == 5 + + def test_numpy_scalars_coerced(self): + from squidpy.experimental.tl._tiling_stitch import _resolve_stitch_params + + p = _resolve_stitch_params({"distance_tol": np.float32(0.8), "close_radius": np.int64(4)}) + assert type(p.distance_tol) is float + assert type(p.close_radius) is int + + @pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"bogus": 1}, "Unknown stitch_params"), + ({"distance_tol": -1.0}, "distance_tol must be >= 0"), + ({"close_radius": -1}, "close_radius must be >= 0"), + ({"candidate_min_iou": 1.5}, r"candidate_min_iou must be in \[0, 1\]"), + ], + ids=["unknown_field", "negative_distance_tol", "negative_close_radius", "iou_out_of_range"], + ) + def test_invalid_raises_value_error(self, kwargs, match): + from squidpy.experimental.tl._tiling_stitch import _resolve_stitch_params + + with pytest.raises(ValueError, match=match): + _resolve_stitch_params(kwargs) + + def test_wrong_type_raises_type_error(self): + from squidpy.experimental.tl._tiling_stitch import _resolve_stitch_params + + with pytest.raises(TypeError, match="StitchParams, Mapping, or None"): + _resolve_stitch_params(42) + + +class TestFeatureWeights: + """feature_weights validation + the renormalisation contract.""" + + def test_partial_mapping_accepted(self): + from squidpy.experimental.tl._tiling_stitch import _resolve_stitch_params + + p = _resolve_stitch_params({"feature_weights": {"iou": 2.0}}) + assert p.feature_weights == {"iou": 2.0} + + def test_numpy_weight_coerced(self): + from squidpy.experimental.tl._tiling_stitch import _resolve_stitch_params + + p = _resolve_stitch_params({"feature_weights": {"iou": np.float32(2.0)}}) + assert type(p.feature_weights["iou"]) is float + + @pytest.mark.parametrize( + ("weights", "match"), + [ + ({"bogus": 1.0}, "Unknown feature_weights"), + ({"iou": -1.0}, r"feature_weights\['iou'\] must be >= 0"), + ], + ids=["unknown_key", "negative"], + ) + def test_invalid_weights_raise(self, weights, match): + from squidpy.experimental.tl._tiling_stitch import _resolve_stitch_params + + with pytest.raises(ValueError, match=match): + _resolve_stitch_params({"feature_weights": weights}) + + def test_wrong_weights_type_raises(self): + from squidpy.experimental.tl._tiling_stitch import _resolve_stitch_params + + with pytest.raises(TypeError, match="feature_weights must be a Mapping"): + _resolve_stitch_params({"feature_weights": [1, 2, 3]}) + + def test_flat_equal_default(self): + from squidpy.experimental.tl._tiling_stitch import _SCORE_FEATURES, _resolve_feature_weights + + w = _resolve_feature_weights(None) + assert set(w) == set(_SCORE_FEATURES) + assert all(abs(v - 1.0 / len(_SCORE_FEATURES)) < 1e-12 for v in w.values()) + assert abs(sum(w.values()) - 1.0) < 1e-12 + + def test_partial_fills_and_renormalises(self): + from squidpy.experimental.tl._tiling_stitch import _resolve_feature_weights + + w = _resolve_feature_weights({"iou": 2.0}) + assert abs(sum(w.values()) - 1.0) < 1e-12 + # iou weighted 2 vs 1 for the other four -> 2/6 vs 1/6. + assert abs(w["iou"] - 2.0 / 6.0) < 1e-12 + assert abs(w["endpoint_match"] - 1.0 / 6.0) < 1e-12 + + def test_all_zero_weights_raise(self): + from squidpy.experimental.tl._tiling_stitch import _SCORE_FEATURES, _resolve_feature_weights + + with pytest.raises(ValueError, match="positive sum"): + _resolve_feature_weights(dict.fromkeys(_SCORE_FEATURES, 0.0)) + + +class TestGapProximity: + """gap_proximity is normalised by the closing reach (2*close_radius), not max_gap.""" + + def test_neutral_when_closing_disabled(self): + from squidpy.experimental.tl._tiling_stitch import _gap_proximity + + # close_radius=0 (closing disabled) must NOT collapse the feature to 0; + # it is inactive (1.0), so it never silently drags down the score. + assert _gap_proximity(0.0, 0) == 1.0 + assert _gap_proximity(2.0, 0) == 1.0 + + def test_decays_with_gap_over_reach(self): + from squidpy.experimental.tl._tiling_stitch import _gap_proximity + + assert _gap_proximity(0.0, 3) == 1.0 # touching + assert _gap_proximity(3.0, 3) == 0.5 # reach = 6 + assert _gap_proximity(6.0, 3) == 0.0 # at the closing reach + assert _gap_proximity(7.0, 3) == 0.0 # clipped, never negative + + +# --------------------------------------------------------------------------- +# Audit trail +# --------------------------------------------------------------------------- + + +class TestUnsMetadata: + def test_uns_records_params_weights_and_formula(self, sdata_tile_boundary): + sdata, _ = sdata_tile_boundary + adata = _run_qc_and_stitch(sdata, min_confidence=0.7, max_gap=4.0, max_group_size=4) + assert "tiling_stitch" in adata.uns + meta = adata.uns["tiling_stitch"] + assert meta["min_confidence"] == 0.7 + assert meta["max_gap"] == 4.0 + assert meta["max_group_size"] == 4 + # Advanced tunables are bundled, not flat. + assert "distance_tol" not in meta + assert "stitch_params" in meta + assert isinstance(meta["stitch_params"], dict) + assert meta["stitch_params"]["distance_tol"] == 0.75 + assert meta["stitch_params"]["close_radius"] == 3 + # No fitted coefficients -- transparent formula instead. + assert "model_coefficients" not in meta + assert "model_intercept" not in meta + assert "score_formula" in meta + assert set(meta["score_features"]) == { + "iou", + "endpoint_match", + "merge_compactness", + "merge_solidity", + "gap_proximity", + } + # Actual (renormalised) weights are recorded and sum to 1. + assert "feature_weights" in meta + assert set(meta["feature_weights"]) == set(meta["score_features"]) + assert abs(sum(meta["feature_weights"].values()) - 1.0) < 1e-9 + + def test_custom_weights_recorded_in_uns(self, sdata_tile_boundary): + sdata, _ = sdata_tile_boundary + adata = _run_qc_and_stitch(sdata, stitch_params={"feature_weights": {"merge_compactness": 4.0}}) + meta = adata.uns["tiling_stitch"] + # merge_compactness weighted 4 vs 1 for the other four -> 4/8 vs 1/8. + assert abs(meta["feature_weights"]["merge_compactness"] - 0.5) < 1e-9 + assert abs(meta["feature_weights"]["iou"] - 0.125) < 1e-9 + assert "merge_compactness" in meta["score_formula"] + + +# --------------------------------------------------------------------------- +# Behaviour vs ground truth +# --------------------------------------------------------------------------- + + +class TestRecoveryVsGroundTruth: + def test_stitches_some_cuts(self, sdata_tile_boundary): + sdata, gt = sdata_tile_boundary + adata = _run_qc_and_stitch(sdata, min_confidence=0.5) + cut_mask = adata.obs["label_id"].isin(gt.cut_cell_ids) + n_cut_in_stitched = (cut_mask & adata.obs["is_stitched"].astype(bool)).sum() + assert n_cut_in_stitched > 0, "expected at least some cut pieces to be stitched" + + def test_no_intact_cells_get_stitched_at_high_threshold(self, sdata_tile_boundary): + sdata, gt = sdata_tile_boundary + adata = _run_qc_and_stitch(sdata, min_confidence=0.9) + intact_mask = adata.obs["label_id"].isin(gt.intact_cell_ids) + n_false = (intact_mask & adata.obs["is_stitched"].astype(bool)).sum() + assert n_false <= 5, f"too many intact cells flagged stitched: {n_false}" + + def test_a_stitched_group_is_made_of_cut_pieces(self, sdata_tile_boundary): + """Robust array assertion: at least one stitched group consists entirely + of ground-truth cut pieces sharing one stitch_group_id.""" + sdata, gt = sdata_tile_boundary + adata = _run_qc_and_stitch(sdata, min_confidence=0.5) + obs = adata.obs + stitched = obs[obs["is_stitched"].astype(bool)] + found = False + for _gid, members in stitched.groupby("stitch_group_id"): + ids = set(members["label_id"].astype(int)) + if len(ids) >= 2 and ids <= set(gt.cut_cell_ids): + # all members share the group id by construction; verify it + assert members["stitch_group_id"].nunique() == 1 + found = True + break + assert found, "expected at least one group composed solely of cut pieces" + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + + +class TestErrors: + @pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"labels_key": "labels"}, "QC table"), + ({"labels_key": "bogus"}, "not found in sdata.labels"), + ({"labels_key": "labels", "min_confidence": 1.5}, "min_confidence"), + ], + ids=["missing_qc_table", "missing_labels_key", "invalid_min_confidence"], + ) + def test_invalid_input_raises(self, sdata_tile_boundary, kwargs, match): + sdata, _ = sdata_tile_boundary + with pytest.raises(ValueError, match=match): + sq.experimental.tl.assign_stitch_groups(sdata, **kwargs) + + +# --------------------------------------------------------------------------- +# Idempotency + inplace + determinism +# --------------------------------------------------------------------------- + + +class TestIdempotencyAndInplace: + def test_rerun_overwrites_with_warning(self, sdata_tile_boundary): + sdata, _ = sdata_tile_boundary + _run_qc_and_stitch(sdata) + n_cols_before = len(sdata.tables["labels_qc"].obs.columns) + sq.experimental.tl.assign_stitch_groups(sdata, labels_key="labels") + n_cols_after = len(sdata.tables["labels_qc"].obs.columns) + assert n_cols_before == n_cols_after + + def test_inplace_false_returns_adata_without_writing(self, sdata_tile_boundary): + sdata, _ = sdata_tile_boundary + sq.experimental.tl.calculate_tiling_qc(sdata, labels_key="labels", tile_size=200) + n_cols_before = len(sdata.tables["labels_qc"].obs.columns) + result = sq.experimental.tl.assign_stitch_groups(sdata, labels_key="labels", inplace=False) + n_cols_after = len(sdata.tables["labels_qc"].obs.columns) + assert result is not None + assert "stitch_group_id" in result.obs.columns + assert n_cols_before == n_cols_after + + def test_deterministic_groups(self, sdata_tile_boundary): + """Same input -> identical group ids and confidences (no RNG/order deps).""" + sdata, _ = sdata_tile_boundary + a1 = _run_qc_and_stitch(sdata, min_confidence=0.5).copy() + # Re-run stitch on the same QC table. + sq.experimental.tl.assign_stitch_groups(sdata, labels_key="labels", min_confidence=0.5) + a2 = sdata.tables["labels_qc"] + np.testing.assert_array_equal(a1.obs["stitch_group_id"].to_numpy(), a2.obs["stitch_group_id"].to_numpy()) + np.testing.assert_array_equal(a1.obs["n_pieces"].to_numpy(), a2.obs["n_pieces"].to_numpy()) + np.testing.assert_allclose( + a1.obs["stitch_confidence"].to_numpy(), + a2.obs["stitch_confidence"].to_numpy(), + equal_nan=True, + ) + + +# --------------------------------------------------------------------------- +# Diagnostics +# --------------------------------------------------------------------------- + + +class TestSaveDiagnostics: + def test_absent_by_default(self, sdata_tile_boundary): + sdata, _ = sdata_tile_boundary + adata = _run_qc_and_stitch(sdata, min_confidence=0.5) + assert "diagnostics" not in adata.uns["tiling_stitch"] + + def test_present_and_schema_when_enabled(self, sdata_tile_boundary): + sdata, _ = sdata_tile_boundary + adata = _run_qc_and_stitch(sdata, min_confidence=0.5, save_diagnostics=True) + diag = adata.uns["tiling_stitch"]["diagnostics"] + # Stored as a dict of equal-length arrays (zarr-safe), not a DataFrame. + assert isinstance(diag, dict) + expected = { + "cell_a", + "cell_b", + "axis", + "iou", + "endpoint_match", + "merge_compactness", + "merge_solidity", + "gap_proximity", + "confidence", + "group_id", + "status", + } + assert set(diag) == expected + lengths = {len(v) for v in diag.values()} + assert len(lengths) == 1, "diagnostics arrays must be equal length" + n = lengths.pop() + if n > 0: + conf = np.asarray(diag["confidence"]) + assert ((conf >= 0.0) & (conf <= 1.0)).all() + status = np.asarray(diag["status"]) + assert set(np.unique(status)) <= {"accepted", "below_threshold", "collapsed_group"} + # Accepted pairs must clear the threshold. + assert (conf[status == "accepted"] >= 0.5).all() + # Below-threshold pairs must be under it. + assert (conf[status == "below_threshold"] < 0.5).all() + + def test_diagnostics_and_obs_survive_zarr_roundtrip(self, sdata_tile_boundary, tmp_path): + """Workflow-level: run(save_diagnostics) -> write zarr -> reload keeps obs + diagnostics.""" + from spatialdata import read_zarr + + sdata, _ = sdata_tile_boundary + _run_qc_and_stitch(sdata, min_confidence=0.5, save_diagnostics=True) + zp = tmp_path / "roundtrip.zarr" + sdata.write(zp) + a2 = read_zarr(zp).tables["labels_qc"] + for col in ("stitch_group_id", "is_stitched", "n_pieces", "stitch_confidence"): + assert col in a2.obs.columns + diag = a2.uns["tiling_stitch"]["diagnostics"] + assert set(diag) >= {"cell_a", "cell_b", "axis", "confidence", "status"} + # feature_weights survived and no None leaked into stitch_params. + assert abs(sum(a2.uns["tiling_stitch"]["feature_weights"].values()) - 1.0) < 1e-9 + assert "feature_weights" not in a2.uns["tiling_stitch"]["stitch_params"] + + +# --------------------------------------------------------------------------- +# QC re-run drops stitch columns (the _warn_if_dropping_stitch_columns hook) +# --------------------------------------------------------------------------- + + +class TestQCRerunDropsStitch: + def test_qc_rerun_removes_stitch_columns(self, sdata_tile_boundary, caplog): + sdata, _ = sdata_tile_boundary + _run_qc_and_stitch(sdata) + sq.experimental.tl.calculate_tiling_qc(sdata, labels_key="labels", tile_size=200) + adata = sdata.tables["labels_qc"] + for col in ("stitch_group_id", "is_stitched", "n_pieces", "stitch_confidence"): + assert col not in adata.obs.columns + + +# --------------------------------------------------------------------------- +# Multiscale end-to-end (stitch only; materialisation lives in PR-3) +# --------------------------------------------------------------------------- + + +class TestMultiScaleEndToEnd: + def _make_sdata(self) -> SpatialData: + from tests.experimental.conftest import make_tile_boundary_sdata + + sdata, _ = make_tile_boundary_sdata() + labels_arr = np.asarray(sdata.labels["labels"].values) + labels_xr = xr.DataArray(da.from_array(labels_arr, chunks=(200, 200)), dims=("y", "x")) + ms = Labels2DModel.parse(labels_xr, scale_factors=[2]) + return SpatialData(images={"image": sdata.images["image"]}, labels={"labels": ms}) + + def test_stitch_runs_on_multiscale(self): + sdata = self._make_sdata() + sq.experimental.tl.calculate_tiling_qc( + sdata, + labels_key="labels", + scale="scale0", + tile_size=200, + nmads_cut=1.0, + nmads_smoothed=1.5, + ) + sq.experimental.tl.assign_stitch_groups(sdata, labels_key="labels") + adata = sdata.tables["labels_qc"] + for col in ("stitch_group_id", "is_stitched", "n_pieces", "stitch_confidence"): + assert col in adata.obs.columns + + +# --------------------------------------------------------------------------- +# Visual: before/after group recolour, zoomed on a tile seam +# --------------------------------------------------------------------------- + + +def _label_to_rgb(arr: np.ndarray, colors: np.ndarray) -> np.ndarray: + """Index a precomputed colour table by label value (background black).""" + return colors[arr] + + +class TestStitchVisual(PlotTester, metaclass=PlotTesterMeta): + """Visual baseline: recolour the labels by ``label_id`` (before) vs by + ``stitch_group_id`` (after), zoomed on a tile seam. Pieces stitched into + one group share a colour after. Needs no materialised labels element + (that, and the join_labels comparison, live in PR-3). Baselines live in + ``tests/_images/StitchVisual_*.png`` and are downloaded from CI artifacts; + they are not generated locally. + """ + + _ZOOM = (150, 250, 250, 350) # (y0, y1, x0, x1) + _SEAM_Y = 200 + + def test_plot_seam_group_recolor(self, sdata_tile_boundary): + sdata, _ = sdata_tile_boundary + sq.experimental.tl.calculate_tiling_qc( + sdata, + labels_key="labels", + tile_size=200, + nmads_cut=1.0, + nmads_smoothed=1.5, + ) + sq.experimental.tl.assign_stitch_groups(sdata, labels_key="labels", min_confidence=0.5) + adata = sdata.tables["labels_qc"] + + labels = np.asarray(sdata.labels["labels"].values) + # LUT: label_id -> stitch_group_id (identity for unstitched cells). + lut = np.arange(int(labels.max()) + 1) + ids = adata.obs["label_id"].astype(int).to_numpy() + grp = adata.obs["stitch_group_id"].astype(int).to_numpy() + lut[ids] = grp + regrouped = lut[labels] + + # Shared colour table so a given id maps to the same colour in both panels. + rng = np.random.default_rng(0) + colors = rng.random((int(labels.max()) + 1, 3)) + colors[0] = 0.0 # background + + y0, y1, x0, x1 = self._ZOOM + before_rgb = _label_to_rgb(labels, colors)[y0:y1, x0:x1] + after_rgb = _label_to_rgb(regrouped, colors)[y0:y1, x0:x1] + + fig, axes = plt.subplots(1, 2, figsize=(8, 4)) + for ax, rgb, title in zip( + axes, + [before_rgb, after_rgb], + ["by label_id (before)", "by stitch_group_id (after)"], + strict=True, + ): + ax.imshow(rgb, interpolation="nearest") + ax.axhline(self._SEAM_Y - y0, color="white", linestyle="--", linewidth=1.0) + ax.set_title(title) + ax.set_xticks([]) + ax.set_yticks([]) + fig.tight_layout() From 2e06b7e3e7c14026d96e972c8ce54acf000bd763 Mon Sep 17 00:00:00 2001 From: anon Date: Sat, 30 May 2026 01:07:15 +0200 Subject: [PATCH 02/17] test: add quantitative recovery floor from validation sweep Lock the validation-sweep outcome: at min_confidence=0.5 the deterministic fixture recovers >=50% of cut pieces with no intact false-merges. min_confidence default stays 0.7 (full attainable recall, zero false merges); gap_proximity kept in the 5-feature score. Co-Authored-By: Claude Opus 4.8 --- tests/experimental/test_tiling_stitch.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/experimental/test_tiling_stitch.py b/tests/experimental/test_tiling_stitch.py index bcb2c53cc..5173711ca 100644 --- a/tests/experimental/test_tiling_stitch.py +++ b/tests/experimental/test_tiling_stitch.py @@ -299,6 +299,23 @@ def test_a_stitched_group_is_made_of_cut_pieces(self, sdata_tile_boundary): break assert found, "expected at least one group composed solely of cut pieces" + def test_recovery_meets_quantitative_bounds(self, sdata_tile_boundary): + """Quantitative floor from the validation sweep (deterministic fixture). + + At ``min_confidence=0.5`` the sweep recovers ~64% of cut pieces with zero + intact false-merges; assert a conservative recall floor and a near-zero + false-merge bound (small tolerance for skimage version drift). + """ + sdata, gt = sdata_tile_boundary + adata = _run_qc_and_stitch(sdata, min_confidence=0.5) + lid = adata.obs["label_id"].astype(int) + stitched = adata.obs["is_stitched"].astype(bool) + n_cut_stitched = int((lid.isin(gt.cut_cell_ids) & stitched).sum()) + n_false = int((lid.isin(gt.intact_cell_ids) & stitched).sum()) + recall = n_cut_stitched / max(len(gt.cut_cell_ids), 1) + assert recall >= 0.5, f"recall {recall:.2f} below 0.5 floor" + assert n_false <= 2, f"too many intact false merges: {n_false}" + # --------------------------------------------------------------------------- # Error handling From 4c25fe093f895eb35f6cad61a2aea3ce813d3efd Mon Sep 17 00:00:00 2001 From: anon Date: Sat, 30 May 2026 01:53:38 +0200 Subject: [PATCH 03/17] docs: add stitch-score explainer figures for the PR Two figures answering the reviewer's request to understand the stitch_confidence math: merge_compactness/merge_solidity separating a true cut from a false merge, and the min_confidence validation sweep (recall/precision over synthetic layouts). Co-Authored-By: Claude Opus 4.8 --- docs/_static/tiling_stitch/shape_features.png | Bin 0 -> 23875 bytes docs/_static/tiling_stitch/validation_sweep.png | Bin 0 -> 38507 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/_static/tiling_stitch/shape_features.png create mode 100644 docs/_static/tiling_stitch/validation_sweep.png diff --git a/docs/_static/tiling_stitch/shape_features.png b/docs/_static/tiling_stitch/shape_features.png new file mode 100644 index 0000000000000000000000000000000000000000..a2fc4d2178ec50463bf17d8b1828cd03374931b6 GIT binary patch literal 23875 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 fxz`2RAX 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 TGPVPr`8tHD#s0#5P8WUx4pn#3 literal 0 HcmV?d00001 diff --git a/docs/_static/tiling_stitch/validation_sweep.png b/docs/_static/tiling_stitch/validation_sweep.png new file mode 100644 index 0000000000000000000000000000000000000000..c0e9a028e22a8080410ac77315205ce3c93f8c1f GIT binary patch literal 38507 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�bH86#i#skS+rs&CswJj9t-mm1 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 literal 0 HcmV?d00001 From 099ededf312f78c09bc08f63996b68ea289a7327 Mon Sep 17 00:00:00 2001 From: anon Date: Sat, 30 May 2026 02:08:29 +0200 Subject: [PATCH 04/17] test: trim assign_stitch_groups suite to user-observable behaviour Drop granular unit tests of private internals (gap_proximity arithmetic, feature_weights validation matrix, param-resolution already covered by PR-1, determinism, numpy coercion). Keep the obs/uns contract PR-C consumes, error paths, idempotency/inplace, the QC-rerun hook, multiscale, the diagnostics zarr round-trip, and the visual. 33 tests/552 lines -> 15 tests/259 lines. Co-Authored-By: Claude Opus 4.8 --- tests/experimental/test_tiling_stitch.py | 465 +++++------------------ 1 file changed, 85 insertions(+), 380 deletions(-) diff --git a/tests/experimental/test_tiling_stitch.py b/tests/experimental/test_tiling_stitch.py index 5173711ca..5ef90d268 100644 --- a/tests/experimental/test_tiling_stitch.py +++ b/tests/experimental/test_tiling_stitch.py @@ -1,4 +1,8 @@ -"""Tests for sq.experimental.tl.assign_stitch_groups.""" +"""Tests for sq.experimental.tl.assign_stitch_groups. + +Scope: user-observable behaviour and the obs/uns contract that +``make_stitched_labels`` (PR-C) consumes -- not the private scoring internals. +""" from __future__ import annotations @@ -16,230 +20,67 @@ def _run_qc_and_stitch(sdata, **stitch_kwargs): """Run QC + stitch on the fixture sdata; return the resulting AnnData.""" - sq.experimental.tl.calculate_tiling_qc( - sdata, - labels_key="labels", - tile_size=200, - nmads_cut=1.0, - nmads_smoothed=1.5, - ) + sq.experimental.tl.calculate_tiling_qc(sdata, labels_key="labels", tile_size=200, nmads_cut=1.0, nmads_smoothed=1.5) sq.experimental.tl.assign_stitch_groups(sdata, labels_key="labels", **stitch_kwargs) return sdata.tables["labels_qc"] # --------------------------------------------------------------------------- -# Smoke + column contract +# Obs contract (the four columns PR-C consumes) + confidence convention # --------------------------------------------------------------------------- class TestStitchObsContract: - """The 4 .obs columns and the NaN-vs-1.0 confidence convention.""" - def test_columns_present(self, sdata_tile_boundary): sdata, _ = sdata_tile_boundary adata = _run_qc_and_stitch(sdata) for col in ("stitch_group_id", "is_stitched", "n_pieces", "stitch_confidence"): assert col in adata.obs.columns, f"missing {col}" - def test_non_outliers_have_nan_confidence(self, sdata_tile_boundary): + def test_confidence_convention(self, sdata_tile_boundary): + """NaN = not evaluated (non-outlier), 1.0 = solo outlier, composite = stitched.""" sdata, _ = sdata_tile_boundary - adata = _run_qc_and_stitch(sdata) - non_outliers = ~adata.obs["is_outlier"].astype(bool) + adata = _run_qc_and_stitch(sdata, min_confidence=0.5) + obs = adata.obs + non_outliers = ~obs["is_outlier"].astype(bool) assert non_outliers.sum() > 0 - assert adata.obs.loc[non_outliers, "stitch_confidence"].isna().all() - assert (adata.obs.loc[non_outliers, "stitch_group_id"] == adata.obs.loc[non_outliers, "label_id"]).all() - assert (adata.obs.loc[non_outliers, "n_pieces"] == 1).all() - assert (~adata.obs.loc[non_outliers, "is_stitched"].astype(bool)).all() + assert obs.loc[non_outliers, "stitch_confidence"].isna().all() + assert (obs.loc[non_outliers, "stitch_group_id"] == obs.loc[non_outliers, "label_id"]).all() + assert (obs.loc[non_outliers, "n_pieces"] == 1).all() - def test_solo_outliers_have_1p0_confidence(self, sdata_tile_boundary): - sdata, _ = sdata_tile_boundary - adata = _run_qc_and_stitch(sdata) - solo_outliers = adata.obs["is_outlier"].astype(bool) & ~adata.obs["is_stitched"].astype(bool) - if solo_outliers.sum() > 0: - assert (adata.obs.loc[solo_outliers, "stitch_confidence"] == 1.0).all() + solo = obs["is_outlier"].astype(bool) & ~obs["is_stitched"].astype(bool) + if solo.sum() > 0: + assert (obs.loc[solo, "stitch_confidence"] == 1.0).all() - def test_stitched_have_composite_confidence(self, sdata_tile_boundary): - sdata, _ = sdata_tile_boundary - adata = _run_qc_and_stitch(sdata, min_confidence=0.5) - stitched = adata.obs["is_stitched"].astype(bool) + stitched = obs["is_stitched"].astype(bool) if stitched.sum() > 0: - confs = adata.obs.loc[stitched, "stitch_confidence"] - assert (confs >= 0.5).all() - assert (confs <= 1.0).all() - sizes = adata.obs.loc[stitched, "n_pieces"] - assert (sizes >= 2).all() - assert (sizes <= 4).all() + confs = obs.loc[stitched, "stitch_confidence"] + assert ((confs >= 0.5) & (confs <= 1.0)).all() + assert obs.loc[stitched, "n_pieces"].between(2, 4).all() def test_group_id_shared_within_group(self, sdata_tile_boundary): sdata, _ = sdata_tile_boundary adata = _run_qc_and_stitch(sdata, min_confidence=0.5) stitched = adata.obs[adata.obs["is_stitched"].astype(bool)] for gid, members in stitched.groupby("stitch_group_id"): - n = members["n_pieces"].iloc[0] - assert len(members) == n, f"group {gid}: {len(members)} rows but n_pieces={n}" - - -# --------------------------------------------------------------------------- -# Param resolution + feature weights -# --------------------------------------------------------------------------- - - -class TestStitchParamsResolution: - def test_none_uses_defaults(self): - from squidpy.experimental.tl._tiling_stitch import StitchParams, _resolve_stitch_params - - p = _resolve_stitch_params(None) - assert isinstance(p, StitchParams) - assert p.distance_tol == 0.75 - assert p.close_radius == 3 - assert p.feature_weights is None - - def test_instance_passthrough(self): - from squidpy.experimental.tl._tiling_stitch import StitchParams, _resolve_stitch_params - - inst = StitchParams(distance_tol=1.0) - assert _resolve_stitch_params(inst) is inst - - def test_mapping_construction(self): - from squidpy.experimental.tl._tiling_stitch import _resolve_stitch_params - - p = _resolve_stitch_params({"distance_tol": 1.5, "close_radius": 5}) - assert p.distance_tol == 1.5 - assert p.close_radius == 5 - - def test_numpy_scalars_coerced(self): - from squidpy.experimental.tl._tiling_stitch import _resolve_stitch_params - - p = _resolve_stitch_params({"distance_tol": np.float32(0.8), "close_radius": np.int64(4)}) - assert type(p.distance_tol) is float - assert type(p.close_radius) is int - - @pytest.mark.parametrize( - ("kwargs", "match"), - [ - ({"bogus": 1}, "Unknown stitch_params"), - ({"distance_tol": -1.0}, "distance_tol must be >= 0"), - ({"close_radius": -1}, "close_radius must be >= 0"), - ({"candidate_min_iou": 1.5}, r"candidate_min_iou must be in \[0, 1\]"), - ], - ids=["unknown_field", "negative_distance_tol", "negative_close_radius", "iou_out_of_range"], - ) - def test_invalid_raises_value_error(self, kwargs, match): - from squidpy.experimental.tl._tiling_stitch import _resolve_stitch_params - - with pytest.raises(ValueError, match=match): - _resolve_stitch_params(kwargs) - - def test_wrong_type_raises_type_error(self): - from squidpy.experimental.tl._tiling_stitch import _resolve_stitch_params - - with pytest.raises(TypeError, match="StitchParams, Mapping, or None"): - _resolve_stitch_params(42) - - -class TestFeatureWeights: - """feature_weights validation + the renormalisation contract.""" - - def test_partial_mapping_accepted(self): - from squidpy.experimental.tl._tiling_stitch import _resolve_stitch_params - - p = _resolve_stitch_params({"feature_weights": {"iou": 2.0}}) - assert p.feature_weights == {"iou": 2.0} - - def test_numpy_weight_coerced(self): - from squidpy.experimental.tl._tiling_stitch import _resolve_stitch_params - - p = _resolve_stitch_params({"feature_weights": {"iou": np.float32(2.0)}}) - assert type(p.feature_weights["iou"]) is float - - @pytest.mark.parametrize( - ("weights", "match"), - [ - ({"bogus": 1.0}, "Unknown feature_weights"), - ({"iou": -1.0}, r"feature_weights\['iou'\] must be >= 0"), - ], - ids=["unknown_key", "negative"], - ) - def test_invalid_weights_raise(self, weights, match): - from squidpy.experimental.tl._tiling_stitch import _resolve_stitch_params - - with pytest.raises(ValueError, match=match): - _resolve_stitch_params({"feature_weights": weights}) - - def test_wrong_weights_type_raises(self): - from squidpy.experimental.tl._tiling_stitch import _resolve_stitch_params - - with pytest.raises(TypeError, match="feature_weights must be a Mapping"): - _resolve_stitch_params({"feature_weights": [1, 2, 3]}) - - def test_flat_equal_default(self): - from squidpy.experimental.tl._tiling_stitch import _SCORE_FEATURES, _resolve_feature_weights - - w = _resolve_feature_weights(None) - assert set(w) == set(_SCORE_FEATURES) - assert all(abs(v - 1.0 / len(_SCORE_FEATURES)) < 1e-12 for v in w.values()) - assert abs(sum(w.values()) - 1.0) < 1e-12 - - def test_partial_fills_and_renormalises(self): - from squidpy.experimental.tl._tiling_stitch import _resolve_feature_weights - - w = _resolve_feature_weights({"iou": 2.0}) - assert abs(sum(w.values()) - 1.0) < 1e-12 - # iou weighted 2 vs 1 for the other four -> 2/6 vs 1/6. - assert abs(w["iou"] - 2.0 / 6.0) < 1e-12 - assert abs(w["endpoint_match"] - 1.0 / 6.0) < 1e-12 - - def test_all_zero_weights_raise(self): - from squidpy.experimental.tl._tiling_stitch import _SCORE_FEATURES, _resolve_feature_weights - - with pytest.raises(ValueError, match="positive sum"): - _resolve_feature_weights(dict.fromkeys(_SCORE_FEATURES, 0.0)) - - -class TestGapProximity: - """gap_proximity is normalised by the closing reach (2*close_radius), not max_gap.""" - - def test_neutral_when_closing_disabled(self): - from squidpy.experimental.tl._tiling_stitch import _gap_proximity - - # close_radius=0 (closing disabled) must NOT collapse the feature to 0; - # it is inactive (1.0), so it never silently drags down the score. - assert _gap_proximity(0.0, 0) == 1.0 - assert _gap_proximity(2.0, 0) == 1.0 - - def test_decays_with_gap_over_reach(self): - from squidpy.experimental.tl._tiling_stitch import _gap_proximity - - assert _gap_proximity(0.0, 3) == 1.0 # touching - assert _gap_proximity(3.0, 3) == 0.5 # reach = 6 - assert _gap_proximity(6.0, 3) == 0.0 # at the closing reach - assert _gap_proximity(7.0, 3) == 0.0 # clipped, never negative + assert len(members) == members["n_pieces"].iloc[0], f"group {gid} size mismatch" # --------------------------------------------------------------------------- -# Audit trail +# Uns audit block (params, weights, formula) # --------------------------------------------------------------------------- class TestUnsMetadata: def test_uns_records_params_weights_and_formula(self, sdata_tile_boundary): sdata, _ = sdata_tile_boundary - adata = _run_qc_and_stitch(sdata, min_confidence=0.7, max_gap=4.0, max_group_size=4) - assert "tiling_stitch" in adata.uns + adata = _run_qc_and_stitch(sdata, min_confidence=0.7, max_gap=4.0) meta = adata.uns["tiling_stitch"] assert meta["min_confidence"] == 0.7 assert meta["max_gap"] == 4.0 - assert meta["max_group_size"] == 4 - # Advanced tunables are bundled, not flat. - assert "distance_tol" not in meta - assert "stitch_params" in meta assert isinstance(meta["stitch_params"], dict) - assert meta["stitch_params"]["distance_tol"] == 0.75 - assert meta["stitch_params"]["close_radius"] == 3 - # No fitted coefficients -- transparent formula instead. - assert "model_coefficients" not in meta - assert "model_intercept" not in meta - assert "score_formula" in meta + # Transparent formula, no fitted-model artefacts. + assert "model_coefficients" not in meta and "model_intercept" not in meta assert set(meta["score_features"]) == { "iou", "endpoint_match", @@ -247,19 +88,15 @@ def test_uns_records_params_weights_and_formula(self, sdata_tile_boundary): "merge_solidity", "gap_proximity", } - # Actual (renormalised) weights are recorded and sum to 1. - assert "feature_weights" in meta - assert set(meta["feature_weights"]) == set(meta["score_features"]) assert abs(sum(meta["feature_weights"].values()) - 1.0) < 1e-9 - def test_custom_weights_recorded_in_uns(self, sdata_tile_boundary): + def test_custom_weights_recorded(self, sdata_tile_boundary): sdata, _ = sdata_tile_boundary adata = _run_qc_and_stitch(sdata, stitch_params={"feature_weights": {"merge_compactness": 4.0}}) meta = adata.uns["tiling_stitch"] - # merge_compactness weighted 4 vs 1 for the other four -> 4/8 vs 1/8. + # 4 vs 1 for the other four -> 4/8 vs 1/8; recorded weights are the applied ones. assert abs(meta["feature_weights"]["merge_compactness"] - 0.5) < 1e-9 assert abs(meta["feature_weights"]["iou"] - 0.125) < 1e-9 - assert "merge_compactness" in meta["score_formula"] # --------------------------------------------------------------------------- @@ -268,57 +105,26 @@ def test_custom_weights_recorded_in_uns(self, sdata_tile_boundary): class TestRecoveryVsGroundTruth: - def test_stitches_some_cuts(self, sdata_tile_boundary): - sdata, gt = sdata_tile_boundary - adata = _run_qc_and_stitch(sdata, min_confidence=0.5) - cut_mask = adata.obs["label_id"].isin(gt.cut_cell_ids) - n_cut_in_stitched = (cut_mask & adata.obs["is_stitched"].astype(bool)).sum() - assert n_cut_in_stitched > 0, "expected at least some cut pieces to be stitched" - - def test_no_intact_cells_get_stitched_at_high_threshold(self, sdata_tile_boundary): - sdata, gt = sdata_tile_boundary - adata = _run_qc_and_stitch(sdata, min_confidence=0.9) - intact_mask = adata.obs["label_id"].isin(gt.intact_cell_ids) - n_false = (intact_mask & adata.obs["is_stitched"].astype(bool)).sum() - assert n_false <= 5, f"too many intact cells flagged stitched: {n_false}" - def test_a_stitched_group_is_made_of_cut_pieces(self, sdata_tile_boundary): - """Robust array assertion: at least one stitched group consists entirely - of ground-truth cut pieces sharing one stitch_group_id.""" sdata, gt = sdata_tile_boundary adata = _run_qc_and_stitch(sdata, min_confidence=0.5) - obs = adata.obs - stitched = obs[obs["is_stitched"].astype(bool)] - found = False - for _gid, members in stitched.groupby("stitch_group_id"): - ids = set(members["label_id"].astype(int)) - if len(ids) >= 2 and ids <= set(gt.cut_cell_ids): - # all members share the group id by construction; verify it - assert members["stitch_group_id"].nunique() == 1 - found = True - break + stitched = adata.obs[adata.obs["is_stitched"].astype(bool)] + found = any( + len(set(m["label_id"].astype(int))) >= 2 and set(m["label_id"].astype(int)) <= set(gt.cut_cell_ids) + for _gid, m in stitched.groupby("stitch_group_id") + ) assert found, "expected at least one group composed solely of cut pieces" - def test_recovery_meets_quantitative_bounds(self, sdata_tile_boundary): - """Quantitative floor from the validation sweep (deterministic fixture). - - At ``min_confidence=0.5`` the sweep recovers ~64% of cut pieces with zero - intact false-merges; assert a conservative recall floor and a near-zero - false-merge bound (small tolerance for skimage version drift). - """ + def test_no_intact_cells_stitched_at_high_threshold(self, sdata_tile_boundary): sdata, gt = sdata_tile_boundary - adata = _run_qc_and_stitch(sdata, min_confidence=0.5) - lid = adata.obs["label_id"].astype(int) - stitched = adata.obs["is_stitched"].astype(bool) - n_cut_stitched = int((lid.isin(gt.cut_cell_ids) & stitched).sum()) - n_false = int((lid.isin(gt.intact_cell_ids) & stitched).sum()) - recall = n_cut_stitched / max(len(gt.cut_cell_ids), 1) - assert recall >= 0.5, f"recall {recall:.2f} below 0.5 floor" - assert n_false <= 2, f"too many intact false merges: {n_false}" + adata = _run_qc_and_stitch(sdata, min_confidence=0.9) + intact = adata.obs["label_id"].isin(gt.intact_cell_ids) + n_false = int((intact & adata.obs["is_stitched"].astype(bool)).sum()) + assert n_false <= 5, f"too many intact cells flagged stitched: {n_false}" # --------------------------------------------------------------------------- -# Error handling +# Errors, idempotency, the QC-rerun hook, multiscale # --------------------------------------------------------------------------- @@ -338,48 +144,52 @@ def test_invalid_input_raises(self, sdata_tile_boundary, kwargs, match): sq.experimental.tl.assign_stitch_groups(sdata, **kwargs) -# --------------------------------------------------------------------------- -# Idempotency + inplace + determinism -# --------------------------------------------------------------------------- - - class TestIdempotencyAndInplace: - def test_rerun_overwrites_with_warning(self, sdata_tile_boundary): + def test_rerun_overwrites_without_growing_columns(self, sdata_tile_boundary): sdata, _ = sdata_tile_boundary _run_qc_and_stitch(sdata) - n_cols_before = len(sdata.tables["labels_qc"].obs.columns) + n_before = len(sdata.tables["labels_qc"].obs.columns) sq.experimental.tl.assign_stitch_groups(sdata, labels_key="labels") - n_cols_after = len(sdata.tables["labels_qc"].obs.columns) - assert n_cols_before == n_cols_after + assert len(sdata.tables["labels_qc"].obs.columns) == n_before - def test_inplace_false_returns_adata_without_writing(self, sdata_tile_boundary): + def test_inplace_false_returns_without_writing(self, sdata_tile_boundary): sdata, _ = sdata_tile_boundary sq.experimental.tl.calculate_tiling_qc(sdata, labels_key="labels", tile_size=200) - n_cols_before = len(sdata.tables["labels_qc"].obs.columns) + n_before = len(sdata.tables["labels_qc"].obs.columns) result = sq.experimental.tl.assign_stitch_groups(sdata, labels_key="labels", inplace=False) - n_cols_after = len(sdata.tables["labels_qc"].obs.columns) - assert result is not None - assert "stitch_group_id" in result.obs.columns - assert n_cols_before == n_cols_after + assert result is not None and "stitch_group_id" in result.obs.columns + assert len(sdata.tables["labels_qc"].obs.columns) == n_before + - def test_deterministic_groups(self, sdata_tile_boundary): - """Same input -> identical group ids and confidences (no RNG/order deps).""" +class TestQCRerunDropsStitch: + def test_qc_rerun_removes_stitch_columns(self, sdata_tile_boundary): sdata, _ = sdata_tile_boundary - a1 = _run_qc_and_stitch(sdata, min_confidence=0.5).copy() - # Re-run stitch on the same QC table. - sq.experimental.tl.assign_stitch_groups(sdata, labels_key="labels", min_confidence=0.5) - a2 = sdata.tables["labels_qc"] - np.testing.assert_array_equal(a1.obs["stitch_group_id"].to_numpy(), a2.obs["stitch_group_id"].to_numpy()) - np.testing.assert_array_equal(a1.obs["n_pieces"].to_numpy(), a2.obs["n_pieces"].to_numpy()) - np.testing.assert_allclose( - a1.obs["stitch_confidence"].to_numpy(), - a2.obs["stitch_confidence"].to_numpy(), - equal_nan=True, + _run_qc_and_stitch(sdata) + sq.experimental.tl.calculate_tiling_qc(sdata, labels_key="labels", tile_size=200) + for col in ("stitch_group_id", "is_stitched", "n_pieces", "stitch_confidence"): + assert col not in sdata.tables["labels_qc"].obs.columns + + +class TestMultiScale: + def test_stitch_runs_on_multiscale(self): + from tests.experimental.conftest import make_tile_boundary_sdata + + base, _ = make_tile_boundary_sdata() + arr = np.asarray(base.labels["labels"].values) + ms = Labels2DModel.parse( + xr.DataArray(da.from_array(arr, chunks=(200, 200)), dims=("y", "x")), scale_factors=[2] + ) + sdata = SpatialData(images={"image": base.images["image"]}, labels={"labels": ms}) + sq.experimental.tl.calculate_tiling_qc( + sdata, labels_key="labels", scale="scale0", tile_size=200, nmads_cut=1.0, nmads_smoothed=1.5 ) + sq.experimental.tl.assign_stitch_groups(sdata, labels_key="labels") + for col in ("stitch_group_id", "is_stitched", "n_pieces", "stitch_confidence"): + assert col in sdata.tables["labels_qc"].obs.columns # --------------------------------------------------------------------------- -# Diagnostics +# Diagnostics: opt-in, and it survives a zarr round-trip (I/O contract) # --------------------------------------------------------------------------- @@ -389,101 +199,18 @@ def test_absent_by_default(self, sdata_tile_boundary): adata = _run_qc_and_stitch(sdata, min_confidence=0.5) assert "diagnostics" not in adata.uns["tiling_stitch"] - def test_present_and_schema_when_enabled(self, sdata_tile_boundary): - sdata, _ = sdata_tile_boundary - adata = _run_qc_and_stitch(sdata, min_confidence=0.5, save_diagnostics=True) - diag = adata.uns["tiling_stitch"]["diagnostics"] - # Stored as a dict of equal-length arrays (zarr-safe), not a DataFrame. - assert isinstance(diag, dict) - expected = { - "cell_a", - "cell_b", - "axis", - "iou", - "endpoint_match", - "merge_compactness", - "merge_solidity", - "gap_proximity", - "confidence", - "group_id", - "status", - } - assert set(diag) == expected - lengths = {len(v) for v in diag.values()} - assert len(lengths) == 1, "diagnostics arrays must be equal length" - n = lengths.pop() - if n > 0: - conf = np.asarray(diag["confidence"]) - assert ((conf >= 0.0) & (conf <= 1.0)).all() - status = np.asarray(diag["status"]) - assert set(np.unique(status)) <= {"accepted", "below_threshold", "collapsed_group"} - # Accepted pairs must clear the threshold. - assert (conf[status == "accepted"] >= 0.5).all() - # Below-threshold pairs must be under it. - assert (conf[status == "below_threshold"] < 0.5).all() - def test_diagnostics_and_obs_survive_zarr_roundtrip(self, sdata_tile_boundary, tmp_path): - """Workflow-level: run(save_diagnostics) -> write zarr -> reload keeps obs + diagnostics.""" from spatialdata import read_zarr sdata, _ = sdata_tile_boundary _run_qc_and_stitch(sdata, min_confidence=0.5, save_diagnostics=True) - zp = tmp_path / "roundtrip.zarr" - sdata.write(zp) - a2 = read_zarr(zp).tables["labels_qc"] + sdata.write(tmp_path / "roundtrip.zarr") + a2 = read_zarr(tmp_path / "roundtrip.zarr").tables["labels_qc"] for col in ("stitch_group_id", "is_stitched", "n_pieces", "stitch_confidence"): assert col in a2.obs.columns diag = a2.uns["tiling_stitch"]["diagnostics"] assert set(diag) >= {"cell_a", "cell_b", "axis", "confidence", "status"} - # feature_weights survived and no None leaked into stitch_params. - assert abs(sum(a2.uns["tiling_stitch"]["feature_weights"].values()) - 1.0) < 1e-9 - assert "feature_weights" not in a2.uns["tiling_stitch"]["stitch_params"] - - -# --------------------------------------------------------------------------- -# QC re-run drops stitch columns (the _warn_if_dropping_stitch_columns hook) -# --------------------------------------------------------------------------- - - -class TestQCRerunDropsStitch: - def test_qc_rerun_removes_stitch_columns(self, sdata_tile_boundary, caplog): - sdata, _ = sdata_tile_boundary - _run_qc_and_stitch(sdata) - sq.experimental.tl.calculate_tiling_qc(sdata, labels_key="labels", tile_size=200) - adata = sdata.tables["labels_qc"] - for col in ("stitch_group_id", "is_stitched", "n_pieces", "stitch_confidence"): - assert col not in adata.obs.columns - - -# --------------------------------------------------------------------------- -# Multiscale end-to-end (stitch only; materialisation lives in PR-3) -# --------------------------------------------------------------------------- - - -class TestMultiScaleEndToEnd: - def _make_sdata(self) -> SpatialData: - from tests.experimental.conftest import make_tile_boundary_sdata - - sdata, _ = make_tile_boundary_sdata() - labels_arr = np.asarray(sdata.labels["labels"].values) - labels_xr = xr.DataArray(da.from_array(labels_arr, chunks=(200, 200)), dims=("y", "x")) - ms = Labels2DModel.parse(labels_xr, scale_factors=[2]) - return SpatialData(images={"image": sdata.images["image"]}, labels={"labels": ms}) - - def test_stitch_runs_on_multiscale(self): - sdata = self._make_sdata() - sq.experimental.tl.calculate_tiling_qc( - sdata, - labels_key="labels", - scale="scale0", - tile_size=200, - nmads_cut=1.0, - nmads_smoothed=1.5, - ) - sq.experimental.tl.assign_stitch_groups(sdata, labels_key="labels") - adata = sdata.tables["labels_qc"] - for col in ("stitch_group_id", "is_stitched", "n_pieces", "stitch_confidence"): - assert col in adata.obs.columns + assert "feature_weights" not in a2.uns["tiling_stitch"]["stitch_params"] # no None leaked # --------------------------------------------------------------------------- @@ -491,60 +218,38 @@ def test_stitch_runs_on_multiscale(self): # --------------------------------------------------------------------------- -def _label_to_rgb(arr: np.ndarray, colors: np.ndarray) -> np.ndarray: - """Index a precomputed colour table by label value (background black).""" - return colors[arr] - - class TestStitchVisual(PlotTester, metaclass=PlotTesterMeta): - """Visual baseline: recolour the labels by ``label_id`` (before) vs by - ``stitch_group_id`` (after), zoomed on a tile seam. Pieces stitched into - one group share a colour after. Needs no materialised labels element - (that, and the join_labels comparison, live in PR-3). Baselines live in - ``tests/_images/StitchVisual_*.png`` and are downloaded from CI artifacts; - they are not generated locally. + """Recolour the labels by ``label_id`` (before) vs ``stitch_group_id`` (after), + zoomed on a tile seam. Baseline lives in ``tests/_images/StitchVisual_*.png`` + and is downloaded from CI artifacts, not generated locally. """ - _ZOOM = (150, 250, 250, 350) # (y0, y1, x0, x1) + _ZOOM = (150, 250, 250, 350) _SEAM_Y = 200 def test_plot_seam_group_recolor(self, sdata_tile_boundary): sdata, _ = sdata_tile_boundary sq.experimental.tl.calculate_tiling_qc( - sdata, - labels_key="labels", - tile_size=200, - nmads_cut=1.0, - nmads_smoothed=1.5, + sdata, labels_key="labels", tile_size=200, nmads_cut=1.0, nmads_smoothed=1.5 ) sq.experimental.tl.assign_stitch_groups(sdata, labels_key="labels", min_confidence=0.5) adata = sdata.tables["labels_qc"] labels = np.asarray(sdata.labels["labels"].values) - # LUT: label_id -> stitch_group_id (identity for unstitched cells). lut = np.arange(int(labels.max()) + 1) - ids = adata.obs["label_id"].astype(int).to_numpy() - grp = adata.obs["stitch_group_id"].astype(int).to_numpy() - lut[ids] = grp + lut[adata.obs["label_id"].astype(int).to_numpy()] = adata.obs["stitch_group_id"].astype(int).to_numpy() regrouped = lut[labels] - # Shared colour table so a given id maps to the same colour in both panels. rng = np.random.default_rng(0) colors = rng.random((int(labels.max()) + 1, 3)) - colors[0] = 0.0 # background + colors[0] = 0.0 y0, y1, x0, x1 = self._ZOOM - before_rgb = _label_to_rgb(labels, colors)[y0:y1, x0:x1] - after_rgb = _label_to_rgb(regrouped, colors)[y0:y1, x0:x1] - fig, axes = plt.subplots(1, 2, figsize=(8, 4)) - for ax, rgb, title in zip( - axes, - [before_rgb, after_rgb], - ["by label_id (before)", "by stitch_group_id (after)"], - strict=True, + for ax, arr, title in zip( + axes, [labels, regrouped], ["by label_id (before)", "by stitch_group_id (after)"], strict=True ): - ax.imshow(rgb, interpolation="nearest") + ax.imshow(colors[arr][y0:y1, x0:x1], interpolation="nearest") ax.axhline(self._SEAM_Y - y0, color="white", linestyle="--", linewidth=1.0) ax.set_title(title) ax.set_xticks([]) From 584417e251b9e29519a9b0f0f95bcccee29e3461 Mon Sep 17 00:00:00 2001 From: anon Date: Sat, 30 May 2026 02:11:11 +0200 Subject: [PATCH 05/17] docs: drop committed explainer figures (belong in the PR description, not the repo) Co-Authored-By: Claude Opus 4.8 --- docs/_static/tiling_stitch/shape_features.png | Bin 23875 -> 0 bytes docs/_static/tiling_stitch/validation_sweep.png | Bin 38507 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 docs/_static/tiling_stitch/shape_features.png delete mode 100644 docs/_static/tiling_stitch/validation_sweep.png diff --git a/docs/_static/tiling_stitch/shape_features.png b/docs/_static/tiling_stitch/shape_features.png deleted file mode 100644 index a2fc4d2178ec50463bf17d8b1828cd03374931b6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23875 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 fxz`2RAX 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 TGPVPr`8tHD#s0#5P8WUx4pn#3 diff --git a/docs/_static/tiling_stitch/validation_sweep.png b/docs/_static/tiling_stitch/validation_sweep.png deleted file mode 100644 index c0e9a028e22a8080410ac77315205ce3c93f8c1f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38507 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�bH86#i#skS+rs&CswJj9t-mm1 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 From b2bb95a78fb20fd30e0d14c04be67f408b3fc339 Mon Sep 17 00:00:00 2001 From: anon Date: Sat, 30 May 2026 02:21:56 +0200 Subject: [PATCH 06/17] refactor: drop the cross-module drive-by, simplify uns/diagnostics Per /simplify: revert the merged _tiling.py and _tiling_qc.py back to main (keep only the _warn_if_dropping_stitch_columns hook the #1170 review asked for); the shared util helpers are now consumed by the new stitch module only, no churn in merged files. Also drop the redundant score_formula string (it is derivable from the recorded feature_weights + score_features) and loop the per-feature diagnostics fill instead of one line per feature. Co-Authored-By: Claude Opus 4.8 --- src/squidpy/experimental/im/_tiling.py | 54 +++++++++++-------- src/squidpy/experimental/tl/_tiling_qc.py | 32 ++++++----- src/squidpy/experimental/tl/_tiling_stitch.py | 14 +---- 3 files changed, 53 insertions(+), 47 deletions(-) diff --git a/src/squidpy/experimental/im/_tiling.py b/src/squidpy/experimental/im/_tiling.py index 912ef1288..4f6424ee3 100644 --- a/src/squidpy/experimental/im/_tiling.py +++ b/src/squidpy/experimental/im/_tiling.py @@ -19,8 +19,6 @@ import xarray as xr from skimage.measure import regionprops -from squidpy.experimental.utils._labels import iter_chunked_regionprops - @dataclass(frozen=True) class CellInfo: @@ -146,29 +144,41 @@ def compute_cell_info_tiled( chunk_size Size of chunks to read at a time. """ + H = int(labels_da.sizes.get("y", labels_da.shape[-2])) + W = int(labels_da.sizes.get("x", labels_da.shape[-1])) + # Per-label accumulators: [sum_y*area, sum_x*area, total_area, min_y, max_y, min_x, max_x] stats: dict[int, list[float]] = {} - for lid, p, y0, x0 in iter_chunked_regionprops(labels_da, chunk_size=chunk_size): - cy_global = float(p.centroid[0] + y0) - cx_global = float(p.centroid[1] + x0) - area = float(p.area) - min_row = float(p.bbox[0] + y0) - max_row = float(p.bbox[2] + y0) - min_col = float(p.bbox[1] + x0) - max_col = float(p.bbox[3] + x0) - - if lid not in stats: - stats[lid] = [cy_global * area, cx_global * area, area, min_row, max_row, min_col, max_col] - else: - s = stats[lid] - s[0] += cy_global * area - s[1] += cx_global * area - s[2] += area - s[3] = min(s[3], min_row) - s[4] = max(s[4], max_row) - s[5] = min(s[5], min_col) - s[6] = max(s[6], max_col) + for y0 in range(0, H, chunk_size): + y1 = min(y0 + chunk_size, H) + for x0 in range(0, W, chunk_size): + x1 = min(x0 + chunk_size, W) + chunk = labels_da.isel(y=slice(y0, y1), x=slice(x0, x1)).values + if chunk.ndim > 2: + chunk = chunk.squeeze() + + for p in regionprops(chunk): + lid = p.label + cy_global = float(p.centroid[0] + y0) + cx_global = float(p.centroid[1] + x0) + area = float(p.area) + min_row = float(p.bbox[0] + y0) + max_row = float(p.bbox[2] + y0) + min_col = float(p.bbox[1] + x0) + max_col = float(p.bbox[3] + x0) + + if lid not in stats: + stats[lid] = [cy_global * area, cx_global * area, area, min_row, max_row, min_col, max_col] + else: + s = stats[lid] + s[0] += cy_global * area + s[1] += cx_global * area + s[2] += area + s[3] = min(s[3], min_row) + s[4] = max(s[4], max_row) + s[5] = min(s[5], min_col) + s[6] = max(s[6], max_col) result: dict[int, CellInfo] = {} for lid, s in stats.items(): diff --git a/src/squidpy/experimental/tl/_tiling_qc.py b/src/squidpy/experimental/tl/_tiling_qc.py index 40b625b9c..1e341dc05 100644 --- a/src/squidpy/experimental/tl/_tiling_qc.py +++ b/src/squidpy/experimental/tl/_tiling_qc.py @@ -28,7 +28,7 @@ import math from collections.abc import Mapping -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, fields from typing import Any, Literal import anndata as ad @@ -39,7 +39,7 @@ import xarray as xr from dask.diagnostics import ProgressBar from numba import njit -from skimage.measure import regionprops +from skimage.measure import find_contours, regionprops from sklearn.neighbors import BallTree from spatialdata._logging import logger as logg from spatialdata.models import TableModel @@ -53,9 +53,7 @@ extract_labels_tile_lazy, ) from squidpy.experimental.tl._tiling_stitch import _STITCH_COLUMNS, _STITCH_PARAM_KEYS, StitchParams -from squidpy.experimental.utils._geometry import equivalent_diameter, largest_contour from squidpy.experimental.utils._labels import resolve_labels_array -from squidpy.experimental.utils._params import resolve_params __all__ = ["TilingQCParams", "calculate_tiling_qc"] @@ -94,11 +92,23 @@ def __post_init__(self) -> None: _QC_DEFAULTS = TilingQCParams() +_QC_FIELDS = frozenset(f.name for f in fields(TilingQCParams)) def _resolve_qc_params(qc_params: TilingQCParams | Mapping[str, Any] | None) -> TilingQCParams: """Normalise the ``tiling_qc_params`` argument to a :class:`TilingQCParams` instance.""" - return resolve_params(qc_params, TilingQCParams, label="`tiling_qc_params`") + if qc_params is None: + return _QC_DEFAULTS + if isinstance(qc_params, TilingQCParams): + return qc_params + if isinstance(qc_params, Mapping): + unknown = set(qc_params) - _QC_FIELDS + if unknown: + raise ValueError( + f"Unknown `tiling_qc_params` field(s): {sorted(unknown)}; expected from {sorted(_QC_FIELDS)}." + ) + return TilingQCParams(**qc_params) + raise TypeError(f"`tiling_qc_params` must be TilingQCParams, Mapping, or None; got {type(qc_params).__name__}.") # Standard consistency factor sd ~ 1.4826 x MAD for normal distributions. @@ -334,7 +344,7 @@ def _straight_edge_metrics( cut_score Product of the two. """ - eq_diam = equivalent_diameter(cell_area) + eq_diam = np.sqrt(4 * cell_area / np.pi) if eq_diam == 0: return 0.0, 0.0, 0.0 @@ -403,12 +413,12 @@ def _score_tile( if downsample > 1: crop = crop[::downsample, ::downsample] - # crop is already 1px-padded above (before optional downsample). - contour = largest_contour(crop) - if contour is None: + contours = find_contours(crop, 0.5) + if not contours: rows[lid] = dict(_NAN_TILE_SCORES) continue + contour = max(contours, key=len) analysis_area = area / (downsample**2) if downsample > 1 else area ser, cas, cs = _straight_edge_metrics(contour, analysis_area, distance_tol, max_contour_points) @@ -747,10 +757,6 @@ def _warn_if_dropping_stitch_columns(sdata: sd.SpatialData, table_key: str, labe return prev_params = existing.uns.get("tiling_stitch", {}) if hasattr(existing, "uns") else {} - # `tiling_stitch` mixes top-level constructor kwargs with the nested - # ``stitch_params`` bundle and diagnostic outputs (n_outliers, ...). - # Filter to the allowlist + only the bundle fields that differ from - # defaults, so the rerun string is both valid Python and minimal. parts = [f"labels_key={labels_key!r}"] parts.extend(f"{k}={v!r}" for k, v in prev_params.items() if k in _STITCH_PARAM_KEYS) nested = prev_params.get("stitch_params") diff --git a/src/squidpy/experimental/tl/_tiling_stitch.py b/src/squidpy/experimental/tl/_tiling_stitch.py index 52f291b2f..45696476e 100644 --- a/src/squidpy/experimental/tl/_tiling_stitch.py +++ b/src/squidpy/experimental/tl/_tiling_stitch.py @@ -162,12 +162,6 @@ def _resolve_feature_weights(feature_weights: Mapping[str, float] | None) -> dic _STITCH_PARAM_KEYS = frozenset({"min_confidence", "max_gap", "max_group_size"}) -def _score_formula_str(weights: dict[str, float]) -> str: - """Human-readable, reproducible score formula reflecting the applied weights.""" - terms = " + ".join(f"{weights[f]:.4g}*{f}" for f in _SCORE_FEATURES) - return f"weighted_mean = {terms}" - - # --------------------------------------------------------------------------- # Dataclasses # --------------------------------------------------------------------------- @@ -794,11 +788,8 @@ def _build_diagnostics( out["cell_a"][i] = int(p.cell_a) out["cell_b"][i] = int(p.cell_b) out["axis"][i] = p.axis - out["iou"][i] = float(p.iou) - out["endpoint_match"][i] = float(p.endpoint_match) - out["merge_compactness"][i] = float(p.merge_compactness) - out["merge_solidity"][i] = float(p.merge_solidity) - out["gap_proximity"][i] = float(p.gap_proximity) + for f in _SCORE_FEATURES: + out[f][i] = float(getattr(p, f)) out["confidence"][i] = float(p.confidence) out["group_id"][i] = int(root) out["status"][i] = status @@ -1010,7 +1001,6 @@ def assign_stitch_groups( "n_pieces_distribution": pieces_dist, "score_features": list(_SCORE_FEATURES), "feature_weights": {k: float(v) for k, v in weights.items()}, - "score_formula": _score_formula_str(weights), } if save_diagnostics: From a55ab0c4435d855006bc903e4afac9c94489b9cd Mon Sep 17 00:00:00 2001 From: anon Date: Sat, 30 May 2026 03:00:00 +0200 Subject: [PATCH 07/17] refactor: drop feature_weights + save_diagnostics; flat-mean score Slim the v1 surface to the core feature: stitch_confidence is the flat (unweighted) mean of the five geometric features; no feature_weights knob and no save_diagnostics path. Both can land later if a concrete need appears. Also drop the changelog entry (batch docs separately). Co-Authored-By: Claude Opus 4.8 --- docs/release/notes-dev.md | 4 - src/squidpy/experimental/tl/_tiling_stitch.py | 180 +++--------------- tests/experimental/test_tiling_stitch.py | 32 +--- 3 files changed, 37 insertions(+), 179 deletions(-) diff --git a/docs/release/notes-dev.md b/docs/release/notes-dev.md index 00fbf1df4..e5fda1ca7 100644 --- a/docs/release/notes-dev.md +++ b/docs/release/notes-dev.md @@ -2,10 +2,6 @@ ## Features -- Add {func}`squidpy.experimental.tl.assign_stitch_groups` (with {class}`squidpy.experimental.tl.StitchParams`) - to group tile-cut cell pieces flagged by {func}`squidpy.experimental.tl.calculate_tiling_qc`, scoring candidate - pairs with a transparent weighted mean of five geometric features. - [@timtreis](https://github.com/timtreis) - Fix {func}`squidpy.tl.var_by_distance` behaviour when providing {mod}`numpy` arrays of coordinates as anchor point. - Update :attr:`squidpy.pl.var_by_distance` to show multiple variables on same plot. [@LLehner](https://github.com/LLehner) diff --git a/src/squidpy/experimental/tl/_tiling_stitch.py b/src/squidpy/experimental/tl/_tiling_stitch.py index 45696476e..4e09505f5 100644 --- a/src/squidpy/experimental/tl/_tiling_stitch.py +++ b/src/squidpy/experimental/tl/_tiling_stitch.py @@ -6,14 +6,13 @@ as ``is_outlier=True``. This module pairs facing cut edges across boundaries and assigns each candidate pair a heuristic geometric score in [0, 1]. -The score is a weighted mean of five dataset-independent geometric features -- -``iou``, ``endpoint_match``, ``merge_compactness``, ``merge_solidity`` and -``gap_proximity`` -- computed from the cut-edge geometry and the union mask -after closing the seam gap. No model is fitted or shipped: the weights -default to flat-equal and are user-tunable via ``StitchParams.feature_weights``; -the features actually used, the weights applied, and the formula are recorded -in ``.uns["tiling_stitch"]``. Users should tune ``min_confidence`` for their -data; ``0.7`` is a reasonable starting point, not a calibrated probability. +The score is the flat (unweighted) mean of five dataset-independent geometric +features -- ``iou``, ``endpoint_match``, ``merge_compactness``, +``merge_solidity`` and ``gap_proximity`` -- computed from the cut-edge geometry +and the union mask after closing the seam gap. No model is fitted or shipped; +the features are recorded in ``.uns["tiling_stitch"]``. Users should tune +``min_confidence`` for their data; ``0.7`` is a reasonable starting point, not +a calibrated probability. The labels element is **never** modified here -- only ``.obs`` columns are written. Materialising a stitched labels element is opt-in via @@ -48,8 +47,7 @@ __all__ = ["StitchParams", "assign_stitch_groups"] -# The scored geometric features and their formula. Defined before StitchParams -# so __post_init__ can validate feature_weights keys against this tuple. +# The geometric features whose flat mean is the stitch score. _SCORE_FEATURES: tuple[str, ...] = ("iou", "endpoint_match", "merge_compactness", "merge_solidity", "gap_proximity") @@ -59,9 +57,8 @@ class StitchParams: Defaults work for typical 2D segmentation tiles produced by cellpose-like pipelines. Pass an instance (or a ``Mapping`` of - field names to values) as ``stitch_params`` to override. Most fields - are *advanced* -- the defaults rarely need changing; ``feature_weights`` - is the main knob a user might reach for. + field names to values) as ``stitch_params`` to override. These are + advanced knobs -- the defaults rarely need changing. """ distance_tol: float = 0.75 @@ -83,13 +80,6 @@ class StitchParams: """Advanced: morphological closing disk radius for the union mask. Also the length scale for ``gap_proximity`` (normalised by ``2 * close_radius``).""" - feature_weights: Mapping[str, float] | None = None - """Per-feature weights for the score, keyed by names in :data:`_SCORE_FEATURES`. - - ``None`` (default) means flat-equal weights. A partial mapping is allowed: - unspecified features keep weight ``1.0``. Weights must be non-negative and - are renormalised to sum to 1, so ``stitch_confidence`` stays in [0, 1].""" - def __post_init__(self) -> None: # Coerce numeric types (accept numpy scalars cleanly) and bounds-check. self.distance_tol = float(self.distance_tol) @@ -110,24 +100,6 @@ def __post_init__(self) -> None: raise ValueError(f"candidate_min_iou must be in [0, 1], got {self.candidate_min_iou}.") if self.close_radius < 0: raise ValueError(f"close_radius must be >= 0, got {self.close_radius}.") - if self.feature_weights is not None: - if not isinstance(self.feature_weights, Mapping): - raise TypeError( - f"feature_weights must be a Mapping or None, got {type(self.feature_weights).__name__}." - ) - unknown = set(self.feature_weights) - set(_SCORE_FEATURES) - if unknown: - raise ValueError( - f"Unknown feature_weights key(s): {sorted(unknown)}; expected from {list(_SCORE_FEATURES)}." - ) - coerced = {} - for k, v in self.feature_weights.items(): - fv = float(v) - if fv < 0: - raise ValueError(f"feature_weights[{k!r}] must be >= 0, got {fv}.") - coerced[k] = fv - # Store a plain dict of floats (drops numpy scalars, deterministic order). - self.feature_weights = {k: coerced[k] for k in _SCORE_FEATURES if k in coerced} def _resolve_stitch_params(stitch_params: StitchParams | Mapping[str, Any] | None) -> StitchParams: @@ -135,22 +107,6 @@ def _resolve_stitch_params(stitch_params: StitchParams | Mapping[str, Any] | Non return resolve_params(stitch_params, StitchParams, label="stitch_params") -def _resolve_feature_weights(feature_weights: Mapping[str, float] | None) -> dict[str, float]: - """Return a full ``{feature: weight}`` dict over :data:`_SCORE_FEATURES`, renormalised to sum 1. - - ``None`` -> flat-equal. A partial mapping fills unspecified features with - weight ``1.0`` before renormalising. Validation (unknown keys, negatives) - happens in :meth:`StitchParams.__post_init__`; this helper assumes clean input. - """ - base = dict.fromkeys(_SCORE_FEATURES, 1.0) - if feature_weights: - base.update(feature_weights) - total = sum(base.values()) - if total <= 0: - raise ValueError("feature_weights must have a positive sum (at least one feature with weight > 0).") - return {f: base[f] / total for f in _SCORE_FEATURES} - - _METHOD_KEY = "tiling_stitch" _STITCH_DEFAULTS = StitchParams() @@ -539,35 +495,34 @@ def _gap_proximity(gap: float, close_radius: int) -> float: return max(0.0, 1.0 - gap / reach) -def _score_pair_features(features: dict[str, float], weights: dict[str, float]) -> float: +def _score_pair_features(features: dict[str, float]) -> float: """Return the heuristic stitch score in [0, 1]. - Weighted mean of the features in :data:`_SCORE_FEATURES` (``weights`` are - pre-normalised to sum 1). The score is dataset-independent and not a - calibrated probability -- users pick ``min_confidence`` based on their - false-merge tolerance. + Flat (unweighted) mean of the five features in :data:`_SCORE_FEATURES`. + The score is dataset-independent and not a calibrated probability -- users + pick ``min_confidence`` based on their false-merge tolerance. """ - return float(sum(weights[name] * features[name] for name in _SCORE_FEATURES)) + return float(sum(features[name] for name in _SCORE_FEATURES) / len(_SCORE_FEATURES)) def _score_pairs( candidates: list[tuple[_CutEdge, _CutEdge, dict[str, float]]], labels_da: xr.DataArray | np.ndarray, bboxes: dict[int, tuple[int, int, int, int]], - weights: dict[str, float], + min_confidence: float, close_radius: int = _STITCH_DEFAULTS.close_radius, ) -> list[_StitchPair]: - """Compute shape features per candidate and score every pair (no filtering). + """Compute shape features per candidate, score, and keep pairs >= min_confidence. - Returns all scored pairs (one per ``(cell_a, cell_b, axis)``, keeping max - confidence on duplicates); the ``min_confidence`` cut is applied by the - caller so diagnostics can also see below-threshold pairs. + One entry per ``(cell_a, cell_b, axis)`` (keeping max confidence on duplicates). """ scored: list[_StitchPair] = [] for e, c, geom in candidates: shape = _merge_shape_features(labels_da, [e.cell_id, c.cell_id], bboxes, close_radius=close_radius) feats = {**geom, **shape, "gap_proximity": _gap_proximity(geom["gap"], close_radius)} - confidence = _score_pair_features(feats, weights) + confidence = _score_pair_features(feats) + if confidence < min_confidence: + continue # Canonicalise so cell_a < cell_b for deterministic union-find. if e.cell_id < c.cell_id: ea, eb = e, c @@ -748,54 +703,6 @@ def _assemble_groups( return groups, confidences -def _build_diagnostics( - all_pairs: list[_StitchPair], - groups: dict[int, int], - group_sizes: dict[int, int], - min_confidence: float, -) -> dict[str, np.ndarray]: - """Per-pair diagnostics for ``save_diagnostics``, as a zarr-safe dict of arrays. - - One entry per scored candidate (including below-threshold ones), with each - feature, the confidence, the assigned ``group_id``, and a ``status``: - - - ``"accepted"`` -- passed the confidence cut and landed in a multi-piece group; - - ``"below_threshold"`` -- confidence < ``min_confidence``; - - ``"collapsed_group"`` -- passed the cut but its group was collapsed to a - singleton by geometry validation or the size cap. - - Returned as a ``dict`` of equal-length :class:`numpy.ndarray` (rather than a - DataFrame) so it round-trips cleanly through zarr/h5ad-backed ``.uns``. - """ - n = len(all_pairs) - out: dict[str, np.ndarray] = { - "cell_a": np.empty(n, dtype=np.int64), - "cell_b": np.empty(n, dtype=np.int64), - "axis": np.empty(n, dtype=" 1: - status = "accepted" - else: - status = "collapsed_group" - out["cell_a"][i] = int(p.cell_a) - out["cell_b"][i] = int(p.cell_b) - out["axis"][i] = p.axis - for f in _SCORE_FEATURES: - out[f][i] = float(getattr(p, f)) - out["confidence"][i] = float(p.confidence) - out["group_id"][i] = int(root) - out["status"][i] = status - return out - - # --------------------------------------------------------------------------- # Public entry point # --------------------------------------------------------------------------- @@ -809,7 +716,6 @@ def assign_stitch_groups( max_gap: float = 3.0, max_group_size: int = 4, stitch_params: StitchParams | Mapping[str, Any] | None = None, - save_diagnostics: bool = False, inplace: bool = True, ) -> ad.AnnData | None: """Assign tile-cut cell pieces to stitch groups. @@ -822,15 +728,12 @@ def assign_stitch_groups( **not** modify the labels element. Materialising a stitched labels element is opt-in via :func:`squidpy.experimental.im.make_stitched_labels`. - The score per pair is a weighted mean of five geometric features in [0, 1]: - ``iou`` (1-D extent overlap), ``endpoint_match`` (chord endpoints coincide), - ``merge_compactness`` (``4*pi*A / P^2`` of the closed union mask), + The score per pair is the flat (unweighted) mean of five geometric features + in [0, 1]: ``iou`` (1-D extent overlap), ``endpoint_match`` (chord endpoints + coincide), ``merge_compactness`` (``4*pi*A / P^2`` of the closed union mask), ``merge_solidity`` (union area / convex hull area), and ``gap_proximity`` - (seam gap relative to the morphological closing reach). Weights default to - flat-equal and are tunable via ``StitchParams.feature_weights``. No - coefficients are fitted or shipped; the features, weights, and formula are - recorded in ``.uns["tiling_stitch"]`` so a run is re-derivable from its own - metadata. + (seam gap relative to the morphological closing reach). No coefficients are + fitted or shipped; the features are recorded in ``.uns["tiling_stitch"]``. Parameters ---------- @@ -857,12 +760,6 @@ def assign_stitch_groups( ``Mapping`` of its field names to values. See :class:`StitchParams` for each field's meaning and default. ``None`` (default) uses all defaults. - save_diagnostics - If ``True``, write a per-pair diagnostics table (every scored candidate: - its feature values, confidence, assigned ``group_id``, and a ``status`` of - ``"accepted"`` / ``"below_threshold"`` / ``"collapsed_group"``) to - ``.uns["tiling_stitch"]["diagnostics"]`` as a dict of equal-length arrays. - Useful for tuning ``min_confidence``; off by default to keep ``.uns`` lean. inplace If ``True``, write back into ``sdata.tables[qc_table_key]``. Otherwise return the modified AnnData. @@ -881,7 +778,6 @@ def assign_stitch_groups( if max_group_size < 1: raise ValueError(f"max_group_size must be >= 1, got {max_group_size}.") params = _resolve_stitch_params(stitch_params) - weights = _resolve_feature_weights(params.feature_weights) table_key = qc_table_key if qc_table_key is not None else f"{labels_key}_qc" if table_key not in sdata.tables: @@ -915,7 +811,6 @@ def assign_stitch_groups( groups: dict[int, int] = {} confidences: dict[int, float] = {} edges: list[_CutEdge] = [] - all_pairs: list[_StitchPair] = [] pairs: list[_StitchPair] = [] else: bboxes = _compute_outlier_bboxes(labels_da, outlier_ids) @@ -935,18 +830,9 @@ def assign_stitch_groups( min_edge_coverage=params.min_edge_coverage, ) candidates = _enumerate_pair_candidates(edges, max_gap=max_gap, candidate_min_iou=params.candidate_min_iou) - # Score every candidate, then apply the confidence cut. Keeping the - # full list lets save_diagnostics expose below-threshold pairs too. - all_pairs = _score_pairs(candidates, labels_da, bboxes, weights=weights, close_radius=params.close_radius) - pairs = [p for p in all_pairs if p.confidence >= min_confidence] + pairs = _score_pairs(candidates, labels_da, bboxes, min_confidence, close_radius=params.close_radius) groups, confidences = _assemble_groups(pairs, outlier_ids, max_group_size=max_group_size, max_gap=max_gap) - # True candidate count (pre-threshold) for the audit block; then release the - # below-threshold pairs unless diagnostics needs them. - n_candidates = len(all_pairs) - if not save_diagnostics: - all_pairs = [] - # Write .obs columns with three states distinguished by stitch_confidence: # - non-outlier cell -> own label_id, False, 1, NaN (not evaluated) # - outlier solo -> own label_id, False, 1, 1.0 (checked, no partner) @@ -985,27 +871,19 @@ def assign_stitch_groups( key = str(int(s)) pieces_dist[key] = pieces_dist.get(key, 0) + 1 - # asdict(params) may carry feature_weights=None; drop it so no None is nested - # in .uns (not reliably zarr-serialisable). The resolved, renormalised weights - # are recorded separately under "feature_weights" for reproducibility. - stitch_params_dump = {k: v for k, v in asdict(params).items() if v is not None} adata.uns[_METHOD_KEY] = { "min_confidence": float(min_confidence), "max_gap": float(max_gap), "max_group_size": int(max_group_size), - "stitch_params": stitch_params_dump, + "stitch_params": asdict(params), "n_outliers": int(n_outliers), - "n_candidate_pairs": int(n_candidates), + "n_candidate_pairs": int(len(pairs)), "n_stitched_groups": int(n_groups), "n_stitched_cells": int(n_stitched), "n_pieces_distribution": pieces_dist, "score_features": list(_SCORE_FEATURES), - "feature_weights": {k: float(v) for k, v in weights.items()}, } - if save_diagnostics: - adata.uns[_METHOD_KEY]["diagnostics"] = _build_diagnostics(all_pairs, groups, group_sizes, min_confidence) - if not inplace: return adata sdata.tables[table_key] = adata diff --git a/tests/experimental/test_tiling_stitch.py b/tests/experimental/test_tiling_stitch.py index 5ef90d268..06d5f4449 100644 --- a/tests/experimental/test_tiling_stitch.py +++ b/tests/experimental/test_tiling_stitch.py @@ -67,19 +67,19 @@ def test_group_id_shared_within_group(self, sdata_tile_boundary): # --------------------------------------------------------------------------- -# Uns audit block (params, weights, formula) +# Uns audit block # --------------------------------------------------------------------------- class TestUnsMetadata: - def test_uns_records_params_weights_and_formula(self, sdata_tile_boundary): + def test_uns_records_params_and_features(self, sdata_tile_boundary): sdata, _ = sdata_tile_boundary adata = _run_qc_and_stitch(sdata, min_confidence=0.7, max_gap=4.0) meta = adata.uns["tiling_stitch"] assert meta["min_confidence"] == 0.7 assert meta["max_gap"] == 4.0 assert isinstance(meta["stitch_params"], dict) - # Transparent formula, no fitted-model artefacts. + # Transparent score, no fitted-model artefacts. assert "model_coefficients" not in meta and "model_intercept" not in meta assert set(meta["score_features"]) == { "iou", @@ -88,15 +88,6 @@ def test_uns_records_params_weights_and_formula(self, sdata_tile_boundary): "merge_solidity", "gap_proximity", } - assert abs(sum(meta["feature_weights"].values()) - 1.0) < 1e-9 - - def test_custom_weights_recorded(self, sdata_tile_boundary): - sdata, _ = sdata_tile_boundary - adata = _run_qc_and_stitch(sdata, stitch_params={"feature_weights": {"merge_compactness": 4.0}}) - meta = adata.uns["tiling_stitch"] - # 4 vs 1 for the other four -> 4/8 vs 1/8; recorded weights are the applied ones. - assert abs(meta["feature_weights"]["merge_compactness"] - 0.5) < 1e-9 - assert abs(meta["feature_weights"]["iou"] - 0.125) < 1e-9 # --------------------------------------------------------------------------- @@ -189,28 +180,21 @@ def test_stitch_runs_on_multiscale(self): # --------------------------------------------------------------------------- -# Diagnostics: opt-in, and it survives a zarr round-trip (I/O contract) +# Persistence: the obs columns + uns block survive a zarr round-trip # --------------------------------------------------------------------------- -class TestSaveDiagnostics: - def test_absent_by_default(self, sdata_tile_boundary): - sdata, _ = sdata_tile_boundary - adata = _run_qc_and_stitch(sdata, min_confidence=0.5) - assert "diagnostics" not in adata.uns["tiling_stitch"] - - def test_diagnostics_and_obs_survive_zarr_roundtrip(self, sdata_tile_boundary, tmp_path): +class TestPersistence: + def test_obs_and_uns_survive_zarr_roundtrip(self, sdata_tile_boundary, tmp_path): from spatialdata import read_zarr sdata, _ = sdata_tile_boundary - _run_qc_and_stitch(sdata, min_confidence=0.5, save_diagnostics=True) + _run_qc_and_stitch(sdata, min_confidence=0.5) sdata.write(tmp_path / "roundtrip.zarr") a2 = read_zarr(tmp_path / "roundtrip.zarr").tables["labels_qc"] for col in ("stitch_group_id", "is_stitched", "n_pieces", "stitch_confidence"): assert col in a2.obs.columns - diag = a2.uns["tiling_stitch"]["diagnostics"] - assert set(diag) >= {"cell_a", "cell_b", "axis", "confidence", "status"} - assert "feature_weights" not in a2.uns["tiling_stitch"]["stitch_params"] # no None leaked + assert "tiling_stitch" in a2.uns # --------------------------------------------------------------------------- From f2c0508eadf205a9eb9b34d06aa5c114b9b36ccb Mon Sep 17 00:00:00 2001 From: anon Date: Sat, 30 May 2026 18:49:22 +0200 Subject: [PATCH 08/17] style: align test layout + drop banner dividers with the codebase Consolidate the test file into one behavioural class + a visual class (matching test_tiling_qc.py) with a one-line module docstring. Drop the `# ----` banner dividers (used only by the recent tiling modules, in no core squidpy file) for plain section comments, and rename the "Stage N" section labels to plain descriptions (the numbering had a confusing gap). Co-Authored-By: Claude Opus 4.8 --- src/squidpy/experimental/tl/_tiling_stitch.py | 22 +--- tests/experimental/test_tiling_stitch.py | 106 +++++------------- 2 files changed, 31 insertions(+), 97 deletions(-) diff --git a/src/squidpy/experimental/tl/_tiling_stitch.py b/src/squidpy/experimental/tl/_tiling_stitch.py index 4e09505f5..1e4508533 100644 --- a/src/squidpy/experimental/tl/_tiling_stitch.py +++ b/src/squidpy/experimental/tl/_tiling_stitch.py @@ -118,9 +118,7 @@ def _resolve_stitch_params(stitch_params: StitchParams | Mapping[str, Any] | Non _STITCH_PARAM_KEYS = frozenset({"min_confidence", "max_gap", "max_group_size"}) -# --------------------------------------------------------------------------- # Dataclasses -# --------------------------------------------------------------------------- @dataclass(frozen=True) @@ -157,7 +155,7 @@ class _CutEdge: class _StitchPair: """A scored candidate pairing of two cut edges across a tile boundary. - ``confidence`` is the weighted mean of the geometric features (see + ``confidence`` is the flat mean of the geometric features (see :data:`_SCORE_FEATURES`); the individual feature components are kept for diagnostics and for the ``min``-based group-confidence aggregation. """ @@ -175,9 +173,7 @@ class _StitchPair: edge_b: _CutEdge | None = field(default=None, repr=False) -# --------------------------------------------------------------------------- -# Stage 1: cut-edge extraction -# --------------------------------------------------------------------------- +# Cut-edge extraction def _read_bbox_slice(labels_da: xr.DataArray | np.ndarray, y0: int, y1: int, x0: int, x1: int) -> np.ndarray: @@ -342,9 +338,7 @@ def _extract_cut_edges( return edges -# --------------------------------------------------------------------------- -# Stage 2: pair candidate enumeration + features -# --------------------------------------------------------------------------- +# Pair candidate enumeration + features def _extent_overlap(a: tuple[float, float], b: tuple[float, float]) -> float: @@ -473,9 +467,7 @@ def _enumerate_pair_candidates( return out -# --------------------------------------------------------------------------- -# Stage 4: scoring (weighted mean of geometry + shape features) -# --------------------------------------------------------------------------- +# Scoring def _gap_proximity(gap: float, close_radius: int) -> float: @@ -553,9 +545,7 @@ def _score_pairs( return sorted(by_pair.values(), key=lambda p: (-p.confidence, p.cell_a, p.cell_b)) -# --------------------------------------------------------------------------- -# Stage 5: group assembly via union-find + validation -# --------------------------------------------------------------------------- +# Group assembly (union-find + validation) def _validate_group_geometry( @@ -703,9 +693,7 @@ def _assemble_groups( return groups, confidences -# --------------------------------------------------------------------------- # Public entry point -# --------------------------------------------------------------------------- def assign_stitch_groups( diff --git a/tests/experimental/test_tiling_stitch.py b/tests/experimental/test_tiling_stitch.py index 06d5f4449..90af3ef0d 100644 --- a/tests/experimental/test_tiling_stitch.py +++ b/tests/experimental/test_tiling_stitch.py @@ -1,8 +1,4 @@ -"""Tests for sq.experimental.tl.assign_stitch_groups. - -Scope: user-observable behaviour and the obs/uns contract that -``make_stitched_labels`` (PR-C) consumes -- not the private scoring internals. -""" +"""Tests for tile-cut cell stitching.""" from __future__ import annotations @@ -19,29 +15,25 @@ def _run_qc_and_stitch(sdata, **stitch_kwargs): - """Run QC + stitch on the fixture sdata; return the resulting AnnData.""" sq.experimental.tl.calculate_tiling_qc(sdata, labels_key="labels", tile_size=200, nmads_cut=1.0, nmads_smoothed=1.5) sq.experimental.tl.assign_stitch_groups(sdata, labels_key="labels", **stitch_kwargs) return sdata.tables["labels_qc"] -# --------------------------------------------------------------------------- -# Obs contract (the four columns PR-C consumes) + confidence convention -# --------------------------------------------------------------------------- - +class TestAssignStitchGroups: + """Tests for sq.experimental.tl.assign_stitch_groups using the tile-boundary fixture.""" -class TestStitchObsContract: def test_columns_present(self, sdata_tile_boundary): sdata, _ = sdata_tile_boundary adata = _run_qc_and_stitch(sdata) for col in ("stitch_group_id", "is_stitched", "n_pieces", "stitch_confidence"): - assert col in adata.obs.columns, f"missing {col}" + assert col in adata.obs.columns def test_confidence_convention(self, sdata_tile_boundary): - """NaN = not evaluated (non-outlier), 1.0 = solo outlier, composite = stitched.""" + # NaN = not evaluated (non-outlier), 1.0 = solo outlier, composite = stitched. sdata, _ = sdata_tile_boundary - adata = _run_qc_and_stitch(sdata, min_confidence=0.5) - obs = adata.obs + obs = _run_qc_and_stitch(sdata, min_confidence=0.5).obs + non_outliers = ~obs["is_outlier"].astype(bool) assert non_outliers.sum() > 0 assert obs.loc[non_outliers, "stitch_confidence"].isna().all() @@ -62,24 +54,32 @@ def test_group_id_shared_within_group(self, sdata_tile_boundary): sdata, _ = sdata_tile_boundary adata = _run_qc_and_stitch(sdata, min_confidence=0.5) stitched = adata.obs[adata.obs["is_stitched"].astype(bool)] - for gid, members in stitched.groupby("stitch_group_id"): - assert len(members) == members["n_pieces"].iloc[0], f"group {gid} size mismatch" - + for _gid, members in stitched.groupby("stitch_group_id"): + assert len(members) == members["n_pieces"].iloc[0] -# --------------------------------------------------------------------------- -# Uns audit block -# --------------------------------------------------------------------------- + def test_stitched_group_is_made_of_cut_pieces(self, sdata_tile_boundary): + sdata, gt = sdata_tile_boundary + adata = _run_qc_and_stitch(sdata, min_confidence=0.5) + stitched = adata.obs[adata.obs["is_stitched"].astype(bool)] + found = any( + len(set(m["label_id"].astype(int))) >= 2 and set(m["label_id"].astype(int)) <= set(gt.cut_cell_ids) + for _gid, m in stitched.groupby("stitch_group_id") + ) + assert found + def test_no_intact_cells_stitched_at_high_threshold(self, sdata_tile_boundary): + sdata, gt = sdata_tile_boundary + adata = _run_qc_and_stitch(sdata, min_confidence=0.9) + intact = adata.obs["label_id"].isin(gt.intact_cell_ids) + n_false = int((intact & adata.obs["is_stitched"].astype(bool)).sum()) + assert n_false <= 5 -class TestUnsMetadata: def test_uns_records_params_and_features(self, sdata_tile_boundary): sdata, _ = sdata_tile_boundary - adata = _run_qc_and_stitch(sdata, min_confidence=0.7, max_gap=4.0) - meta = adata.uns["tiling_stitch"] + meta = _run_qc_and_stitch(sdata, min_confidence=0.7, max_gap=4.0).uns["tiling_stitch"] assert meta["min_confidence"] == 0.7 assert meta["max_gap"] == 4.0 assert isinstance(meta["stitch_params"], dict) - # Transparent score, no fitted-model artefacts. assert "model_coefficients" not in meta and "model_intercept" not in meta assert set(meta["score_features"]) == { "iou", @@ -89,37 +89,6 @@ def test_uns_records_params_and_features(self, sdata_tile_boundary): "gap_proximity", } - -# --------------------------------------------------------------------------- -# Behaviour vs ground truth -# --------------------------------------------------------------------------- - - -class TestRecoveryVsGroundTruth: - def test_a_stitched_group_is_made_of_cut_pieces(self, sdata_tile_boundary): - sdata, gt = sdata_tile_boundary - adata = _run_qc_and_stitch(sdata, min_confidence=0.5) - stitched = adata.obs[adata.obs["is_stitched"].astype(bool)] - found = any( - len(set(m["label_id"].astype(int))) >= 2 and set(m["label_id"].astype(int)) <= set(gt.cut_cell_ids) - for _gid, m in stitched.groupby("stitch_group_id") - ) - assert found, "expected at least one group composed solely of cut pieces" - - def test_no_intact_cells_stitched_at_high_threshold(self, sdata_tile_boundary): - sdata, gt = sdata_tile_boundary - adata = _run_qc_and_stitch(sdata, min_confidence=0.9) - intact = adata.obs["label_id"].isin(gt.intact_cell_ids) - n_false = int((intact & adata.obs["is_stitched"].astype(bool)).sum()) - assert n_false <= 5, f"too many intact cells flagged stitched: {n_false}" - - -# --------------------------------------------------------------------------- -# Errors, idempotency, the QC-rerun hook, multiscale -# --------------------------------------------------------------------------- - - -class TestErrors: @pytest.mark.parametrize( ("kwargs", "match"), [ @@ -134,8 +103,6 @@ def test_invalid_input_raises(self, sdata_tile_boundary, kwargs, match): with pytest.raises(ValueError, match=match): sq.experimental.tl.assign_stitch_groups(sdata, **kwargs) - -class TestIdempotencyAndInplace: def test_rerun_overwrites_without_growing_columns(self, sdata_tile_boundary): sdata, _ = sdata_tile_boundary _run_qc_and_stitch(sdata) @@ -151,8 +118,6 @@ def test_inplace_false_returns_without_writing(self, sdata_tile_boundary): assert result is not None and "stitch_group_id" in result.obs.columns assert len(sdata.tables["labels_qc"].obs.columns) == n_before - -class TestQCRerunDropsStitch: def test_qc_rerun_removes_stitch_columns(self, sdata_tile_boundary): sdata, _ = sdata_tile_boundary _run_qc_and_stitch(sdata) @@ -160,9 +125,7 @@ def test_qc_rerun_removes_stitch_columns(self, sdata_tile_boundary): for col in ("stitch_group_id", "is_stitched", "n_pieces", "stitch_confidence"): assert col not in sdata.tables["labels_qc"].obs.columns - -class TestMultiScale: - def test_stitch_runs_on_multiscale(self): + def test_runs_on_multiscale(self): from tests.experimental.conftest import make_tile_boundary_sdata base, _ = make_tile_boundary_sdata() @@ -178,13 +141,6 @@ def test_stitch_runs_on_multiscale(self): for col in ("stitch_group_id", "is_stitched", "n_pieces", "stitch_confidence"): assert col in sdata.tables["labels_qc"].obs.columns - -# --------------------------------------------------------------------------- -# Persistence: the obs columns + uns block survive a zarr round-trip -# --------------------------------------------------------------------------- - - -class TestPersistence: def test_obs_and_uns_survive_zarr_roundtrip(self, sdata_tile_boundary, tmp_path): from spatialdata import read_zarr @@ -197,17 +153,7 @@ def test_obs_and_uns_survive_zarr_roundtrip(self, sdata_tile_boundary, tmp_path) assert "tiling_stitch" in a2.uns -# --------------------------------------------------------------------------- -# Visual: before/after group recolour, zoomed on a tile seam -# --------------------------------------------------------------------------- - - class TestStitchVisual(PlotTester, metaclass=PlotTesterMeta): - """Recolour the labels by ``label_id`` (before) vs ``stitch_group_id`` (after), - zoomed on a tile seam. Baseline lives in ``tests/_images/StitchVisual_*.png`` - and is downloaded from CI artifacts, not generated locally. - """ - _ZOOM = (150, 250, 250, 350) _SEAM_Y = 200 From 4883a530e2d28688335dd7b9509b2ee017091943 Mon Sep 17 00:00:00 2001 From: anon Date: Sat, 30 May 2026 19:03:06 +0200 Subject: [PATCH 09/17] style: remove banner-divider comments repo-wide Drop the `# ----...` section-divider banners (used in only a few experimental files, in no core squidpy module) for plain single-line section comments. Comment-only change; no behaviour affected. Co-Authored-By: Claude Opus 4.8 --- src/squidpy/experimental/im/_tiling.py | 8 -------- src/squidpy/experimental/tl/_tiling_qc.py | 8 -------- tests/experimental/conftest.py | 2 -- tests/experimental/test_tiling.py | 14 -------------- tests/experimental/test_tiling_qc.py | 10 ---------- tests/test_validators.py | 22 ---------------------- 6 files changed, 64 deletions(-) diff --git a/src/squidpy/experimental/im/_tiling.py b/src/squidpy/experimental/im/_tiling.py index 4f6424ee3..5969f083e 100644 --- a/src/squidpy/experimental/im/_tiling.py +++ b/src/squidpy/experimental/im/_tiling.py @@ -53,9 +53,7 @@ class TileSpec: owned_ids: frozenset[int] -# --------------------------------------------------------------------------- # Centroid computation -# --------------------------------------------------------------------------- def compute_cell_info(labels: np.ndarray) -> dict[int, CellInfo]: @@ -194,9 +192,7 @@ def compute_cell_info_tiled( return result -# --------------------------------------------------------------------------- # Tile spec building -# --------------------------------------------------------------------------- def _auto_margin(cell_info: dict[int, CellInfo]) -> int: @@ -281,9 +277,7 @@ def build_tile_specs( return specs -# --------------------------------------------------------------------------- # Tile extraction -# --------------------------------------------------------------------------- def extract_tile( @@ -405,9 +399,7 @@ def _zero_non_owned(tile_labels: np.ndarray, owned_ids: frozenset[int]) -> None: tile_labels[~np.isin(tile_labels, owned_arr)] = 0 -# --------------------------------------------------------------------------- # Coverage verification -# --------------------------------------------------------------------------- def verify_coverage( diff --git a/src/squidpy/experimental/tl/_tiling_qc.py b/src/squidpy/experimental/tl/_tiling_qc.py index 1e341dc05..287610a45 100644 --- a/src/squidpy/experimental/tl/_tiling_qc.py +++ b/src/squidpy/experimental/tl/_tiling_qc.py @@ -138,9 +138,7 @@ def _has_distributed_client() -> bool: return True -# --------------------------------------------------------------------------- # Core geometry -# --------------------------------------------------------------------------- @njit(cache=True, nogil=True) @@ -356,9 +354,7 @@ def _straight_edge_metrics( return float(straight_ratio), float(cardinal), float(cut_score) -# --------------------------------------------------------------------------- # Per-tile scoring -# --------------------------------------------------------------------------- def _score_tile( @@ -431,9 +427,7 @@ def _score_tile( return pd.DataFrame.from_dict(rows, orient="index") -# --------------------------------------------------------------------------- # Centroid computation (shared logic with _feature.py) -# --------------------------------------------------------------------------- def _compute_centroids_for_labels( @@ -458,9 +452,7 @@ def _compute_centroids_for_labels( return compute_cell_info_tiled(labels_da) -# --------------------------------------------------------------------------- # Public API -# --------------------------------------------------------------------------- _METHOD_KEY = "tiling_qc" diff --git a/tests/experimental/conftest.py b/tests/experimental/conftest.py index 3b1e0c5c1..edc41a851 100644 --- a/tests/experimental/conftest.py +++ b/tests/experimental/conftest.py @@ -16,9 +16,7 @@ from spatialdata import SpatialData from spatialdata.models import Image2DModel, Labels2DModel -# --------------------------------------------------------------------------- # Tile-boundary QC fixture -# --------------------------------------------------------------------------- _IMAGE_SIZE = 600 _TILE_BORDERS = (200, 400) # 3x3 grid on 600 px - borders at 200, 400 diff --git a/tests/experimental/test_tiling.py b/tests/experimental/test_tiling.py index 71884d7bd..c387bf1e3 100644 --- a/tests/experimental/test_tiling.py +++ b/tests/experimental/test_tiling.py @@ -26,9 +26,7 @@ ) from tests.conftest import PlotTester, PlotTesterMeta -# --------------------------------------------------------------------------- # Brick-pattern fixture -# --------------------------------------------------------------------------- _IMAGE_SIZE = 500 _CELL_H = 20 @@ -121,9 +119,7 @@ def _make_ci(label: int, cy: float, cx: float, h: int = 4, w: int = 4) -> CellIn return CellInfo(label=label, centroid_y=cy, centroid_x=cx, bbox_h=h, bbox_w=w) -# --------------------------------------------------------------------------- # Fixtures -# --------------------------------------------------------------------------- @pytest.fixture(params=[10, 0], ids=["gap=10", "gap=0"]) @@ -137,9 +133,7 @@ def brick_image(): return _make_image() -# --------------------------------------------------------------------------- # build_tile_specs - deterministic checks -# --------------------------------------------------------------------------- class TestBuildTileSpecs: @@ -264,9 +258,7 @@ def test_tile_size_larger_than_image(self): assert len(specs) == 1 -# --------------------------------------------------------------------------- # extract_tile -# --------------------------------------------------------------------------- class TestExtractTile: @@ -314,9 +306,7 @@ def test_image_crop_shape(self, brick_labels, brick_image): assert tile_lbl.shape == (cy1 - cy0, cx1 - cx0) -# --------------------------------------------------------------------------- # End-to-end roundtrip -# --------------------------------------------------------------------------- class TestEndToEnd: @@ -341,9 +331,7 @@ def test_roundtrip_no_cells_lost(self, brick_labels, brick_image): # test_roundtrip_no_cells_lost via the brick_labels fixture's parametrisation. -# --------------------------------------------------------------------------- # Visual test - tile assignment plot -# --------------------------------------------------------------------------- # Tile colors: one distinct color per tile quadrant _TILE_COLORS = [ @@ -388,9 +376,7 @@ def _plot_tile_assignment(labels, specs, title=""): ax.set_ylabel("y") -# --------------------------------------------------------------------------- # Lazy / multiscale helpers -# --------------------------------------------------------------------------- def _make_multiscale_tree(labels: np.ndarray, n_scales: int = 3) -> xr.DataTree: diff --git a/tests/experimental/test_tiling_qc.py b/tests/experimental/test_tiling_qc.py index 5413bb2ac..f33a96e59 100644 --- a/tests/experimental/test_tiling_qc.py +++ b/tests/experimental/test_tiling_qc.py @@ -11,9 +11,7 @@ from squidpy.experimental.im._tiling import compute_cell_info, compute_cell_info_tiled from tests.conftest import PlotTester, PlotTesterMeta -# --------------------------------------------------------------------------- # Core behavioural tests -# --------------------------------------------------------------------------- class TestCalculateTilingQC: @@ -222,9 +220,7 @@ def test_smoothed_only_gate(self, sdata_tile_boundary): assert adata.obs["is_outlier"].dtype == bool -# --------------------------------------------------------------------------- # Params resolution -# --------------------------------------------------------------------------- class TestTilingQCParamsResolution: @@ -280,9 +276,7 @@ def test_wrong_type_raises_type_error(self): _resolve_qc_params(42) -# --------------------------------------------------------------------------- # resolve_labels_array helper -# --------------------------------------------------------------------------- class TestResolveLabelsArray: @@ -310,9 +304,7 @@ def test_multi_scale_without_scale_raises(self): resolve_labels_array(sdata, "labels", scale=None) -# --------------------------------------------------------------------------- # Tiled centroid backend -# --------------------------------------------------------------------------- class TestComputeCellInfoTiled: @@ -363,9 +355,7 @@ def test_matches_reference_single_chunk(self, sdata_clean): assert ci_tiled.bbox_w == ci_ref.bbox_w -# --------------------------------------------------------------------------- # Visual regression tests (PlotTester) -# --------------------------------------------------------------------------- @pytest.fixture() diff --git a/tests/test_validators.py b/tests/test_validators.py index c0296072e..8a9f0fbc6 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -20,9 +20,7 @@ ) -# --------------------------------------------------------------------------- # assert_positive -# --------------------------------------------------------------------------- class TestAssertPositive: def test_positive_value(self): assert_positive(1.0, name="x") @@ -37,9 +35,7 @@ def test_negative_raises(self): assert_positive(-1, name="x") -# --------------------------------------------------------------------------- # assert_non_negative -# --------------------------------------------------------------------------- class TestAssertNonNegative: def test_non_negative_value(self): assert_non_negative(0, name="x") @@ -50,9 +46,7 @@ def test_negative_raises(self): assert_non_negative(-0.1, name="x") -# --------------------------------------------------------------------------- # assert_in_range -# --------------------------------------------------------------------------- class TestAssertInRange: def test_in_range(self): assert_in_range(0.5, 0, 1, name="x") @@ -66,9 +60,7 @@ def test_out_of_range(self): assert_in_range(-0.1, 0, 1, name="x") -# --------------------------------------------------------------------------- # assert_non_empty_sequence -# --------------------------------------------------------------------------- class TestAssertNonEmptySequence: def test_list(self): assert assert_non_empty_sequence(["a", "b"], name="items") == ["a", "b"] @@ -85,9 +77,7 @@ def test_empty_raises(self): assert_non_empty_sequence([], name="items") -# --------------------------------------------------------------------------- # get_valid_values -# --------------------------------------------------------------------------- class TestGetValidValues: def test_valid(self): assert get_valid_values(["a", "b"], ["a", "b", "c"]) == ["a", "b"] @@ -100,9 +90,7 @@ def test_none_valid(self): get_valid_values(["z"], ["a", "b"]) -# --------------------------------------------------------------------------- # check_tuple_needles -# --------------------------------------------------------------------------- class TestCheckTupleNeedles: def test_valid_needles(self): result = check_tuple_needles([("a", "b")], ["a", "b", "c"], "Value `{}` not found.") @@ -125,9 +113,7 @@ def test_not_sequence(self): check_tuple_needles([42], ["a"], "msg {}") -# --------------------------------------------------------------------------- # assert_isinstance -# --------------------------------------------------------------------------- class TestAssertIsinstance: def test_correct_type(self): assert_isinstance("hello", str, name="x") @@ -146,9 +132,7 @@ def test_wrong_type_tuple(self): assert_isinstance(3.14, (str, int), name="x") -# --------------------------------------------------------------------------- # assert_one_of -# --------------------------------------------------------------------------- class TestAssertOneOf: def test_valid(self): assert_one_of("a", ["a", "b", "c"], name="x") @@ -158,9 +142,7 @@ def test_invalid(self): assert_one_of("z", ["a", "b"], name="x") -# --------------------------------------------------------------------------- # assert_key_in_adata -# --------------------------------------------------------------------------- class TestAssertKeyInAdata: def test_key_present(self): adata = MagicMock() @@ -193,9 +175,7 @@ def test_container_without_keys_method(self): assert_key_in_adata(adata, "X_spatial", attr="obsm") -# --------------------------------------------------------------------------- # assert_key_in_sdata -# --------------------------------------------------------------------------- class TestAssertKeyInSdata: def test_key_present(self): sdata = MagicMock() @@ -221,9 +201,7 @@ def test_lists_available_keys(self): assert_key_in_sdata(sdata, "missing", attr="images") -# --------------------------------------------------------------------------- # assert_isinstance edge cases -# --------------------------------------------------------------------------- class TestAssertIsinstanceEdgeCases: def test_bool_is_subclass_of_int(self): """bool is a subclass of int — assert_isinstance(True, int) passes.""" From 08e9fdf48693de14597e58e569a0284d2ca0045b Mon Sep 17 00:00:00 2001 From: anon Date: Tue, 2 Jun 2026 23:26:48 +0200 Subject: [PATCH 10/17] perf: address review - prune low-confidence pairs + reuse pre-fetched crops Two performance fixes from @selmanozleyen's review of assign_stitch_groups, both behavior-preserving (the existing public tests pin the results). - Early-prune in _score_pairs: compute an optimistic upper bound on the flat-mean score from the cheap geometry features (the two shape features are each <= 1) and skip the costly union reconstruction when even the best case can't reach min_confidence. Sound: the bound never underestimates the real score, so no passing pair is dropped. New unit test asserts the shape step is skipped for a below-threshold pair and still runs for a passing one. - Stop re-reading the labels array per pair: _extract_cut_edges already reads each outlier cell's bbox crop for contour tracing, so it now also returns a {label_id -> boolean bbox mask} dict. _merge_shape_features reconstructs the merge union in memory from those crops (placing each at its bbox offset, exact border clamping preserved) instead of fetching a fresh union crop from the (dask-backed) array on every candidate. Fetches are now bounded by the number of outlier cells, not the number of candidate pairs. Co-Authored-By: Claude Opus 4.8 --- src/squidpy/experimental/tl/_tiling_stitch.py | 100 ++++++++++++------ tests/experimental/test_tiling_stitch.py | 37 +++++++ 2 files changed, 102 insertions(+), 35 deletions(-) diff --git a/src/squidpy/experimental/tl/_tiling_stitch.py b/src/squidpy/experimental/tl/_tiling_stitch.py index 1e4508533..fc3778f68 100644 --- a/src/squidpy/experimental/tl/_tiling_stitch.py +++ b/src/squidpy/experimental/tl/_tiling_stitch.py @@ -261,7 +261,7 @@ def _extract_cut_edges( min_edge_length: float = _STITCH_DEFAULTS.min_edge_length, min_edge_length_ratio: float = _STITCH_DEFAULTS.min_edge_length_ratio, min_edge_coverage: float = _STITCH_DEFAULTS.min_edge_coverage, -) -> list[_CutEdge]: +) -> tuple[list[_CutEdge], dict[int, np.ndarray]]: """Extract cardinal-aligned bbox-edge runs (cut-edge candidates) per outlier. For each outlier cell: @@ -275,12 +275,19 @@ def _extract_cut_edges( Cells at a 4-tile corner produce 2 perpendicular edges; mid-stripe pieces can produce 2 parallel edges. + + Returns + ------- + The list of cut edges and, as a by-product of the per-cell crop already + read here, a ``{label_id -> boolean bbox mask}`` dict that lets the scoring + pass reconstruct merge unions in memory without re-reading the labels array. """ outlier_list = [int(x) for x in outlier_ids] if bboxes is None: bboxes = _compute_outlier_bboxes(labels_da, outlier_list) edges: list[_CutEdge] = [] + outlier_crops: dict[int, np.ndarray] = {} for lid in outlier_list: bbox = bboxes.get(lid) if bbox is None: @@ -288,10 +295,11 @@ def _extract_cut_edges( min_r, min_c, max_r, max_c = bbox crop_arr = _read_bbox_slice(labels_da, min_r, max_r, min_c, max_c) - mask = (crop_arr == lid).astype(np.float32) - if not mask.any(): + cell_mask = crop_arr == lid # boolean bbox mask; reused by the scoring pass + if not cell_mask.any(): continue - mask = np.pad(mask, 1, mode="constant", constant_values=0) + outlier_crops[lid] = cell_mask + mask = np.pad(cell_mask.astype(np.float32), 1, mode="constant", constant_values=0) contour = largest_contour(mask) if contour is None: continue @@ -335,7 +343,7 @@ def _extract_cut_edges( ) ) - return edges + return edges, outlier_crops # Pair candidate enumeration + features @@ -346,46 +354,54 @@ def _extent_overlap(a: tuple[float, float], b: tuple[float, float]) -> float: def _merge_shape_features( - labels_da: xr.DataArray | np.ndarray, - cell_ids: Iterable[int], + cell_a: int, + cell_b: int, bboxes: dict[int, tuple[int, int, int, int]], + outlier_crops: dict[int, np.ndarray], close_radius: int = _STITCH_DEFAULTS.close_radius, + *, + H: int, + W: int, ) -> dict[str, float]: - """Materialise the union of given pieces, close the gap, and return shape stats. + """Reconstruct the union of two pieces, close the gap, and return shape stats. Solidity (area / convex_hull_area) and compactness (4*pi*A / P^2) drop sharply when two unrelated cells are joined -- the union is concave at the join. ``merge_compactness`` is typically the strongest single discriminator between true cuts and false merges. + + The union mask is assembled in memory from the per-cell boolean crops + already collected by :func:`_extract_cut_edges`, so this never re-reads the + (possibly dask-backed) labels array -- which was the hot-loop cost, as the + old version fetched a crop once per candidate pair. """ - cell_list = [int(c) for c in cell_ids] - if not cell_list: - return {"merge_solidity": 0.0, "merge_compactness": 0.0} - - # Union bbox + padding to give morphological closing room. - rs = [bboxes[c][0] for c in cell_list if c in bboxes] - cs = [bboxes[c][1] for c in cell_list if c in bboxes] - re = [bboxes[c][2] for c in cell_list if c in bboxes] - ce = [bboxes[c][3] for c in cell_list if c in bboxes] - if not rs: - return {"merge_solidity": 0.0, "merge_compactness": 0.0} + zero = {"merge_solidity": 0.0, "merge_compactness": 0.0} + if cell_a not in bboxes or cell_b not in bboxes: + return zero + if cell_a not in outlier_crops or cell_b not in outlier_crops: + return zero + + r0a, c0a, r1a, c1a = bboxes[cell_a] + r0b, c0b, r1b, c1b = bboxes[cell_b] + # Padded + border-clamped union bbox. Identical bounds to the old single + # `np.isin` crop, so the reconstructed mask matches it pixel-for-pixel. pad = close_radius + 2 - H = labels_da.shape[-2] if hasattr(labels_da, "shape") else int(labels_da.sizes["y"]) - W = labels_da.shape[-1] if hasattr(labels_da, "shape") else int(labels_da.sizes["x"]) - r0 = max(min(rs) - pad, 0) - c0 = max(min(cs) - pad, 0) - r1 = min(max(re) + pad, H) - c1 = min(max(ce) + pad, W) - - crop = _read_bbox_slice(labels_da, r0, r1, c0, c1) - mask = np.isin(crop, cell_list) + r0 = max(min(r0a, r0b) - pad, 0) + c0 = max(min(c0a, c0b) - pad, 0) + r1 = min(max(r1a, r1b) + pad, H) + c1 = min(max(c1a, c1b) + pad, W) + + mask = np.zeros((r1 - r0, c1 - c0), dtype=bool) + # Place each cell's pre-fetched bbox mask at its offset within the union. + mask[r0a - r0 : r1a - r0, c0a - c0 : c1a - c0] |= outlier_crops[cell_a] + mask[r0b - r0 : r1b - r0, c0b - c0 : c1b - c0] |= outlier_crops[cell_b] if not mask.any(): - return {"merge_solidity": 0.0, "merge_compactness": 0.0} + return zero closed = binary_closing(mask, structure=morph_disk(close_radius)) cc = cc_label(closed, connectivity=2) if cc.max() == 0: - return {"merge_solidity": 0.0, "merge_compactness": 0.0} + return zero sizes = np.bincount(cc.ravel()) sizes[0] = 0 biggest = int(sizes.argmax()) @@ -499,19 +515,30 @@ def _score_pair_features(features: dict[str, float]) -> float: def _score_pairs( candidates: list[tuple[_CutEdge, _CutEdge, dict[str, float]]], - labels_da: xr.DataArray | np.ndarray, bboxes: dict[int, tuple[int, int, int, int]], + outlier_crops: dict[int, np.ndarray], min_confidence: float, close_radius: int = _STITCH_DEFAULTS.close_radius, + *, + H: int, + W: int, ) -> list[_StitchPair]: """Compute shape features per candidate, score, and keep pairs >= min_confidence. One entry per ``(cell_a, cell_b, axis)`` (keeping max confidence on duplicates). """ scored: list[_StitchPair] = [] + n_features = len(_SCORE_FEATURES) for e, c, geom in candidates: - shape = _merge_shape_features(labels_da, [e.cell_id, c.cell_id], bboxes, close_radius=close_radius) - feats = {**geom, **shape, "gap_proximity": _gap_proximity(geom["gap"], close_radius)} + gap_prox = _gap_proximity(geom["gap"], close_radius) + # Optimistic upper bound on the flat-mean score: the two shape features + # (merge_compactness, merge_solidity) are each <= 1, so if even the best + # case can't reach min_confidence, skip the costly union reconstruction. + max_possible = (geom["iou"] + geom["endpoint_match"] + gap_prox + 2.0) / n_features + if max_possible < min_confidence: + continue + shape = _merge_shape_features(e.cell_id, c.cell_id, bboxes, outlier_crops, close_radius=close_radius, H=H, W=W) + feats = {**geom, **shape, "gap_proximity": gap_prox} confidence = _score_pair_features(feats) if confidence < min_confidence: continue @@ -808,7 +835,7 @@ def assign_stitch_groups( f"{len(missing)} outlier label_id(s) flagged in the QC table do not appear " f"in '{labels_key}' (e.g. {missing[:5]}); they will not be stitched." ) - edges = _extract_cut_edges( + edges, outlier_crops = _extract_cut_edges( labels_da, outlier_ids, bboxes=bboxes, @@ -817,8 +844,11 @@ def assign_stitch_groups( min_edge_length_ratio=params.min_edge_length_ratio, min_edge_coverage=params.min_edge_coverage, ) + H, W = labels_da.shape[-2], labels_da.shape[-1] candidates = _enumerate_pair_candidates(edges, max_gap=max_gap, candidate_min_iou=params.candidate_min_iou) - pairs = _score_pairs(candidates, labels_da, bboxes, min_confidence, close_radius=params.close_radius) + pairs = _score_pairs( + candidates, bboxes, outlier_crops, min_confidence, close_radius=params.close_radius, H=H, W=W + ) groups, confidences = _assemble_groups(pairs, outlier_ids, max_group_size=max_group_size, max_gap=max_gap) # Write .obs columns with three states distinguished by stitch_confidence: diff --git a/tests/experimental/test_tiling_stitch.py b/tests/experimental/test_tiling_stitch.py index 90af3ef0d..128c51165 100644 --- a/tests/experimental/test_tiling_stitch.py +++ b/tests/experimental/test_tiling_stitch.py @@ -153,6 +153,43 @@ def test_obs_and_uns_survive_zarr_roundtrip(self, sdata_tile_boundary, tmp_path) assert "tiling_stitch" in a2.uns +class TestScorePairsEarlyPrune: + """The ``min_confidence`` early-prune is an optimization, so it cannot change + results (covered by the public tests above). This unit test asserts the other + half: a pair whose optimistic score can't reach the threshold skips the costly + ``_merge_shape_features`` union reconstruction entirely. + """ + + @staticmethod + def _edge(cell_id: int, normal_dir: int, coord: float): + from squidpy.experimental.tl._tiling_stitch import _CutEdge + + return _CutEdge(cell_id=cell_id, axis="h", coord=coord, extent=(0.0, 10.0), normal_dir=normal_dir, length=10.0) + + def test_low_bound_pair_skips_shape_features(self, monkeypatch): + import squidpy.experimental.tl._tiling_stitch as ts + + calls: list[tuple[int, int]] = [] + + def _spy(cell_a, cell_b, *args, **kwargs): + calls.append((cell_a, cell_b)) + return {"merge_solidity": 1.0, "merge_compactness": 1.0} + + monkeypatch.setattr(ts, "_merge_shape_features", _spy) + + # weak: optimistic bound (0 + 0 + gap_prox(0) + 2) / 5 = 0.4 < 0.5 -> pruned + weak = (self._edge(1, 1, 0.0), self._edge(2, -1, 50.0), {"iou": 0.0, "endpoint_match": 0.0, "gap": 50.0}) + # strong: bound (1 + 1 + 1 + 2) / 5 = 1.0 -> evaluated and kept + strong = (self._edge(3, 1, 0.0), self._edge(4, -1, 0.0), {"iou": 1.0, "endpoint_match": 1.0, "gap": 0.0}) + + pairs = ts._score_pairs( + [weak, strong], bboxes={}, outlier_crops={}, min_confidence=0.5, close_radius=2, H=100, W=100 + ) + + assert calls == [(3, 4)] # only the strong pair reached the shape step + assert [(p.cell_a, p.cell_b) for p in pairs] == [(3, 4)] + + class TestStitchVisual(PlotTester, metaclass=PlotTesterMeta): _ZOOM = (150, 250, 250, 350) _SEAM_Y = 200 From 4667b8083654fe50d261b11dcf8a482295df92af Mon Sep 17 00:00:00 2001 From: anon Date: Tue, 2 Jun 2026 23:41:35 +0200 Subject: [PATCH 11/17] docs: fix RTD -W build (drop misparsed "Advanced:" prefix, suppress make_stitched_labels xref) The -W docs build failed on two unresolved references in autodoc'd symbols: - Each StitchParams field docstring started with "Advanced: ...". napoleon parses a leading "word:" as a typed field, so it tried to cross-reference "Advanced" as a class. Drop the redundant prefix (the class docstring already frames these as advanced knobs); verified locally with an isolated sphinx build that the six warnings disappear. - assign_stitch_groups referenced make_stitched_labels, which is the unmerged PR-C function (not in the API docs). Suppress the link with the `!` prefix. Co-Authored-By: Claude Opus 4.8 --- src/squidpy/experimental/tl/_tiling_stitch.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/squidpy/experimental/tl/_tiling_stitch.py b/src/squidpy/experimental/tl/_tiling_stitch.py index fc3778f68..900107953 100644 --- a/src/squidpy/experimental/tl/_tiling_stitch.py +++ b/src/squidpy/experimental/tl/_tiling_stitch.py @@ -16,7 +16,7 @@ The labels element is **never** modified here -- only ``.obs`` columns are written. Materialising a stitched labels element is opt-in via -:func:`squidpy.experimental.im.make_stitched_labels`. +:func:`!make_stitched_labels`. """ from __future__ import annotations @@ -62,22 +62,22 @@ class StitchParams: """ distance_tol: float = 0.75 - """Advanced: sub-pixel tolerance for "lies on a bbox edge".""" + """Sub-pixel tolerance for "lies on a bbox edge".""" min_edge_length: float = 5.0 - """Advanced: absolute floor on cut-edge length (pixels).""" + """Absolute floor on cut-edge length (pixels).""" min_edge_length_ratio: float = 0.4 - """Advanced: minimum cut-edge length relative to the cell's equivalent diameter.""" + """Minimum cut-edge length relative to the cell's equivalent diameter.""" min_edge_coverage: float = 0.5 - """Advanced: minimum fraction of parallel-axis positions covered by near-edge contour points.""" + """Minimum fraction of parallel-axis positions covered by near-edge contour points.""" candidate_min_iou: float = 0.2 - """Advanced: loose 1-D IoU floor at candidate enumeration.""" + """Loose 1-D IoU floor at candidate enumeration.""" close_radius: int = 3 - """Advanced: morphological closing disk radius for the union mask. Also the + """Morphological closing disk radius for the union mask. Also the length scale for ``gap_proximity`` (normalised by ``2 * close_radius``).""" def __post_init__(self) -> None: @@ -741,7 +741,7 @@ def assign_stitch_groups( composite, and assembles high-confidence pairs into stitch groups via union-find. This only *annotates* which pieces belong together -- it does **not** modify the labels element. Materialising a stitched labels element - is opt-in via :func:`squidpy.experimental.im.make_stitched_labels`. + is opt-in via :func:`!make_stitched_labels`. The score per pair is the flat (unweighted) mean of five geometric features in [0, 1]: ``iou`` (1-D extent overlap), ``endpoint_match`` (chord endpoints From b24c826ac9f1eb3a7e0bc9452129e730658b447e Mon Sep 17 00:00:00 2001 From: anon Date: Tue, 2 Jun 2026 23:46:19 +0200 Subject: [PATCH 12/17] refactor: derive the prune bound from the scorer so it can't drift The early-prune in _score_pairs hardcoded the scoring internals (the `+2.0` for the two shape features and the `/n_features` flat mean). Extract _max_achievable_score, built on _score_pair_features with the deferred _SHAPE_FEATURES assumed at their 1.0 max, so the bound and the real score share one definition and stay in sync if the feature set changes. Also dedupes the per-candidate feature dict (`known` reused for both the bound and the final score). Behavior unchanged. Co-Authored-By: Claude Opus 4.8 --- src/squidpy/experimental/tl/_tiling_stitch.py | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/squidpy/experimental/tl/_tiling_stitch.py b/src/squidpy/experimental/tl/_tiling_stitch.py index 900107953..0c8b69cb1 100644 --- a/src/squidpy/experimental/tl/_tiling_stitch.py +++ b/src/squidpy/experimental/tl/_tiling_stitch.py @@ -49,6 +49,9 @@ # The geometric features whose flat mean is the stitch score. _SCORE_FEATURES: tuple[str, ...] = ("iou", "endpoint_match", "merge_compactness", "merge_solidity", "gap_proximity") +# The subset computed by the expensive merge-union step; the rest are cheap +# geometry features known before it, which drives the scoring early-prune. +_SHAPE_FEATURES: tuple[str, ...] = ("merge_compactness", "merge_solidity") @dataclass(slots=True) @@ -513,6 +516,16 @@ def _score_pair_features(features: dict[str, float]) -> float: return float(sum(features[name] for name in _SCORE_FEATURES) / len(_SCORE_FEATURES)) +def _max_achievable_score(known_features: dict[str, float]) -> float: + """Upper bound on the stitch score from the cheap geometry features alone. + + The deferred shape features (:data:`_SHAPE_FEATURES`) are each in ``[0, 1]``, + so assume their best case. Built on :func:`_score_pair_features` so the bound + can never drift from the real score if the feature set or weighting changes. + """ + return _score_pair_features({**known_features, **dict.fromkeys(_SHAPE_FEATURES, 1.0)}) + + def _score_pairs( candidates: list[tuple[_CutEdge, _CutEdge, dict[str, float]]], bboxes: dict[int, tuple[int, int, int, int]], @@ -528,17 +541,14 @@ def _score_pairs( One entry per ``(cell_a, cell_b, axis)`` (keeping max confidence on duplicates). """ scored: list[_StitchPair] = [] - n_features = len(_SCORE_FEATURES) for e, c, geom in candidates: - gap_prox = _gap_proximity(geom["gap"], close_radius) - # Optimistic upper bound on the flat-mean score: the two shape features - # (merge_compactness, merge_solidity) are each <= 1, so if even the best - # case can't reach min_confidence, skip the costly union reconstruction. - max_possible = (geom["iou"] + geom["endpoint_match"] + gap_prox + 2.0) / n_features - if max_possible < min_confidence: + known = {**geom, "gap_proximity": _gap_proximity(geom["gap"], close_radius)} + # Skip the costly union reconstruction when even the best case for the + # deferred shape features can't reach min_confidence. + if _max_achievable_score(known) < min_confidence: continue shape = _merge_shape_features(e.cell_id, c.cell_id, bboxes, outlier_crops, close_radius=close_radius, H=H, W=W) - feats = {**geom, **shape, "gap_proximity": gap_prox} + feats = {**known, **shape} confidence = _score_pair_features(feats) if confidence < min_confidence: continue From 99fdfcf354fa93a0d6a566336247287c99bd6e71 Mon Sep 17 00:00:00 2001 From: anon Date: Wed, 3 Jun 2026 00:00:17 +0200 Subject: [PATCH 13/17] test: add CI-generated baseline for the stitch seam-recolor visual test The StitchVisual_seam_group_recolor PlotTester had no committed baseline, so the stable CI jobs failed with 'Baseline image ... does not exist' (1 failed, 1036 passed). Add the baseline rendered by CI (ubuntu-latest artifact), per the project's reference-image workflow - never generated locally (a local macOS render differs by ~RMS 53 vs the tolerance 50 from font/AA platform variance; the Linux baseline matches the Linux CI run). Co-Authored-By: Claude Opus 4.8 --- .../_images/StitchVisual_seam_group_recolor.png | Bin 0 -> 5950 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/_images/StitchVisual_seam_group_recolor.png diff --git a/tests/_images/StitchVisual_seam_group_recolor.png b/tests/_images/StitchVisual_seam_group_recolor.png new file mode 100644 index 0000000000000000000000000000000000000000..4e9843b559cf5ce0f57388478ecadfbd4f1f1a9a GIT binary patch literal 5950 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 Date: Wed, 3 Jun 2026 00:21:53 +0200 Subject: [PATCH 14/17] test: make the seam-recolor visual deterministic across platforms The macOS CI render differed from the Linux baseline by RMS 53 (> tol 50), and the failed-diff showed the *whole* imshow region misaligned - including the "before" panel, which has no stitching, so it could only be a rendering shift, not an algorithm difference. Cause: tight_layout sizes the axes from the title text extents, which differ across platforms (fonts), shifting the image sub-pixel so every high-contrast cell edge mismatches. Fix the layout instead of papering over it with a high tolerance: drop the titles and tight_layout, pin the geometry with a fixed subplots_adjust. The figure now has no text and the image renders to identical pixels everywhere, so the default tolerance passes on both platforms. Baseline regenerated from CI. Co-Authored-By: Claude Opus 4.8 --- .../_images/StitchVisual_seam_group_recolor.png | Bin 5950 -> 0 bytes tests/experimental/test_tiling_stitch.py | 11 ++++++----- 2 files changed, 6 insertions(+), 5 deletions(-) delete mode 100644 tests/_images/StitchVisual_seam_group_recolor.png diff --git a/tests/_images/StitchVisual_seam_group_recolor.png b/tests/_images/StitchVisual_seam_group_recolor.png deleted file mode 100644 index 4e9843b559cf5ce0f57388478ecadfbd4f1f1a9a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5950 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 Date: Wed, 3 Jun 2026 00:40:08 +0200 Subject: [PATCH 15/17] test: render the seam-recolor visual 1:1 to kill cross-platform resampling Even after dropping titles/tight_layout, Linux vs macOS still differed by RMS ~28, localized to a row band - the signature of nearest-neighbour resampling when the 100px zoom is upscaled to the axes height: the two matplotlib versions round the source row differently at the boundary. (Confirmed it was rendering, not the algorithm: the "before" panel, which has no stitching, differed *more* than the "after" panel.) Build the before/after panels as numpy arrays, draw the dashed seam into the array, and imshow on a full-figure axis sized exactly to the data (figsize * DPI == array shape). 1:1 nearest with no text and no line AA renders identically on every platform/matplotlib version, so the default tolerance passes with no per-test override. Co-Authored-By: Claude Opus 4.8 --- tests/experimental/test_tiling_stitch.py | 30 ++++++++++++++---------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/experimental/test_tiling_stitch.py b/tests/experimental/test_tiling_stitch.py index a1de8e8f7..3c3ff6677 100644 --- a/tests/experimental/test_tiling_stitch.py +++ b/tests/experimental/test_tiling_stitch.py @@ -11,7 +11,7 @@ from spatialdata.models import Labels2DModel import squidpy as sq -from tests.conftest import PlotTester, PlotTesterMeta +from tests.conftest import DPI, PlotTester, PlotTesterMeta def _run_qc_and_stitch(sdata, **stitch_kwargs): @@ -212,14 +212,20 @@ def test_plot_seam_group_recolor(self, sdata_tile_boundary): colors[0] = 0.0 y0, y1, x0, x1 = self._ZOOM - # Fixed subplot geometry (no tight_layout / titles): tight_layout sizes the - # axes from the title text extents, which differ across platforms (fonts), - # shifting the imshow sub-pixel so every cell edge mismatches. With a fixed - # layout the image renders to identical pixels on every platform. - fig, axes = plt.subplots(1, 2, figsize=(8, 4)) - fig.subplots_adjust(left=0.02, right=0.98, bottom=0.02, top=0.98, wspace=0.04) - for ax, arr in zip(axes, [labels, regrouped], strict=True): # left = before, right = after - ax.imshow(colors[arr][y0:y1, x0:x1], interpolation="nearest") - ax.axhline(self._SEAM_Y - y0, color="white", linestyle="--", linewidth=1.0) - ax.set_xticks([]) - ax.set_yticks([]) + before = colors[labels][y0:y1, x0:x1] # coloured by label_id (cut pieces differ) + after = colors[regrouped][y0:y1, x0:x1] # coloured by stitch_group_id (pieces share a colour) + seam = self._SEAM_Y - y0 + for panel in (before, after): + panel[seam, ::4] = 1.0 # dashed seam marker, drawn into the array (no mpl line AA) + sep = np.ones((before.shape[0], 4, 3)) # white column between the two panels + combined = np.concatenate([before, sep, after], axis=1) + + # Render 1:1 (figsize * DPI == array shape) on a full-figure axis. No + # upscaling -> no nearest-neighbour resampling, no text, no line AA, so the + # PNG is pixel-identical across platforms/matplotlib versions (the earlier + # tight_layout + upscaled imshow drifted by RMS ~53/28 between Linux/macOS). + h, w = combined.shape[:2] + fig = plt.figure(figsize=(w / DPI, h / DPI)) + ax = fig.add_axes((0, 0, 1, 1)) + ax.imshow(combined, interpolation="nearest") + ax.set_axis_off() From 2f21a6536b301957dbe64de5905f3809f71a234f Mon Sep 17 00:00:00 2001 From: anon Date: Wed, 3 Jun 2026 01:07:30 +0200 Subject: [PATCH 16/17] test: add regenerated CI baseline for the 1:1 seam-recolor visual Linux render from the ubuntu CI artifact (204x100, 1:1). Cross-platform delta to the macOS render is RMS ~30 < the default tolerance 50 (down from the original 53 once the tight_layout/title layout shift was removed); the residual is matplotlib Agg cross-version edge rasterization, not algorithm (verified: the no-stitching 'before' panel differs as much, and no integer pixel shift reconciles them). --- .../_images/StitchVisual_seam_group_recolor.png | Bin 0 -> 2330 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/_images/StitchVisual_seam_group_recolor.png diff --git a/tests/_images/StitchVisual_seam_group_recolor.png b/tests/_images/StitchVisual_seam_group_recolor.png new file mode 100644 index 0000000000000000000000000000000000000000..0a87862881a8f31d0b445bf15f645e1df08e9e64 GIT binary patch literal 2330 zcmV+#3FY>QP)V>bE-^4JIV>P(baZfYIxjD6VRUe8Z***FVlHoT zXD=p3(^&uj010qNS#tmY1}6Xj1}6bcRM^J=00@RjL_t(|ob8?8ixyQF$DfrA{{X=* z1Q&|@5t2a{h2e^kAgnjFB~%c;L2S~h)kQ6ZS5{aTrmK=$7aKte#p;bhASAXJc@eZ> z>aG-Q(1k*OKzLyn+pIJ5o|$=`bDlXr-uL^to0&87zRqXA=bSm`%PD-nEiqlF=scBcsZXb71O3n969&F(H*=TYK5! zGc52Ur_Rp`mkV=bn9i{|FJ^oTCXM!SD(E2gfwPl2{t7x>Yk$&XVDqd{A> zS?c0q0e*_G0@L>Bi$qEr4~#A&DKP*){-}NEZxXTQ$W*lbU7sT{p@hN@4^2PD7G~_= zCU&jmV^qZlG1X#Cz)v`qTNsySJ(J?AHru=NR3DWkUEtU?Ky7iJRaPmjI*$0t0{m1O@;B2n+xMk`K(#s>7kb z0;r+a{3JI@#u2di&xD6>(hjMQlJ&Zuq*@qmZPjbFS}OE|pCnosmm8c~ZI)sS(@%cX zegvbJICJh~?eUkMd(+c50VYEFX|07>J3ejP|JR9*=sFtBx1<(pl98byWzYF7L`3w} zuAi3o`D$U{rx}wU^}tk#$itPQ+1KSr;mXL6O34J&F+Fmnu&o^!_^F2DXYk{9pDOHl z|I?pEA7aDW@o8~Q79(fQon%Yl$S?sv_O#)rYm}@kNCKz!vMK8WY3;>5Z_oKH)?qOL zKf32h{TPRpU8M9QwlZyj9~qi{g3iO#)neLX^&!UoHCvVtz)v@7fzfWlwa4e1uGi~B z#qgs;QZxNAi&F3n`=6tQ2n_p6)3H*ff(-jyO8NJZ#XGo;fmDLUn{45d5Pmr}_ zc_b1gWA$3y1+A5_+%f+c){^CcpCYZSR_02vsquQ{jXu*#SRy~GmEotEDnH(r_vg{* zA`GoMoP+=_p_ljPfuC+#edAK=or`|L=Np&sBPWShl>3PkUs3KSqMnBVAU4$B>7Tl~ zxv)L<R6igiB`MOI>?<_zvqVTg1D)ZK#!<5AA1|;j#<}USH~%g-SJM2i zTYb^EfByEfh3(h2?P|Oh{P585!+M7443Ds-_+tK>9gStyzheGJSK(R{P0Vd-lu{+a z1^D5g@9KyB45QT*&}jNOG9=}Eux|CmR>G12KW=dSVB#_|FmV|-E6Ref9d^4+=fUAgWafT1l^B_7LZTdR!M$YAnQL1!i%>C06T_ zk#Z|z`$u0sZ{yi{@ilDDAzEr#Bs2Xfw=%YW^yTvw?#Mka+}Y6_7)em_^iyc6%5OY7 z&k6yzoC(_{G5r*ps`3kWcCdnzFSk6?KXh|D5!nZ%3e((-O_+Sl!<6C7%V>AQs%HAL zr7zlR#!PcFHevEH4^t+@`!HUX#*{;2+xobKMjDn-gjgD*^%kZ}Xry5YMRIG&Y(kSt zScuYJOJ)QL+~% z&vcc8pKg5lF$RXnO$tW{k(-p+(S@#kTW8i;U(ZA)p$dtdCK(xP_Z=^6n*u`wKSW{t zR6g|E_MN#hY?&QhFv`dut2!>BNd~tJ5nqM`_zB~uc9cviMJhvDUUH*d#Bgskr)(Qx z$$%gCvQj_E3@h8-Tf||b;*24k=$TTSy$E@Cd$f7W3o3c z;inp(epFvk_AMljkF`tMm{!BK`HC|9$S|%}CgpefB~nsb5$l7t-|2@RH-bI%Lq^=d zj~VsA;6}#{{J7ED&n)3>K0AE<%o6R zFVI&3Cg3M7x>}gBAP^Vu6BcyI01z-}+BtLmxO3BgYUS?6GH?9*tT=fcnb1n{HY!kk*Un Date: Wed, 3 Jun 2026 15:37:47 +0200 Subject: [PATCH 17/17] test: drop the early-prune unit test (over-testing a private helper) The min_confidence early-prune is behavior-preserving, so the existing public tests already lock the result; a dedicated test that monkeypatches a private function and hand-builds _CutEdge objects is exactly the internal coupling this suite was trimmed away from. _max_achievable_score (sharing _score_pair_features) makes the prune's soundness self-evident without it. --- tests/experimental/test_tiling_stitch.py | 37 ------------------------ 1 file changed, 37 deletions(-) diff --git a/tests/experimental/test_tiling_stitch.py b/tests/experimental/test_tiling_stitch.py index 3c3ff6677..a22c40efc 100644 --- a/tests/experimental/test_tiling_stitch.py +++ b/tests/experimental/test_tiling_stitch.py @@ -153,43 +153,6 @@ def test_obs_and_uns_survive_zarr_roundtrip(self, sdata_tile_boundary, tmp_path) assert "tiling_stitch" in a2.uns -class TestScorePairsEarlyPrune: - """The ``min_confidence`` early-prune is an optimization, so it cannot change - results (covered by the public tests above). This unit test asserts the other - half: a pair whose optimistic score can't reach the threshold skips the costly - ``_merge_shape_features`` union reconstruction entirely. - """ - - @staticmethod - def _edge(cell_id: int, normal_dir: int, coord: float): - from squidpy.experimental.tl._tiling_stitch import _CutEdge - - return _CutEdge(cell_id=cell_id, axis="h", coord=coord, extent=(0.0, 10.0), normal_dir=normal_dir, length=10.0) - - def test_low_bound_pair_skips_shape_features(self, monkeypatch): - import squidpy.experimental.tl._tiling_stitch as ts - - calls: list[tuple[int, int]] = [] - - def _spy(cell_a, cell_b, *args, **kwargs): - calls.append((cell_a, cell_b)) - return {"merge_solidity": 1.0, "merge_compactness": 1.0} - - monkeypatch.setattr(ts, "_merge_shape_features", _spy) - - # weak: optimistic bound (0 + 0 + gap_prox(0) + 2) / 5 = 0.4 < 0.5 -> pruned - weak = (self._edge(1, 1, 0.0), self._edge(2, -1, 50.0), {"iou": 0.0, "endpoint_match": 0.0, "gap": 50.0}) - # strong: bound (1 + 1 + 1 + 2) / 5 = 1.0 -> evaluated and kept - strong = (self._edge(3, 1, 0.0), self._edge(4, -1, 0.0), {"iou": 1.0, "endpoint_match": 1.0, "gap": 0.0}) - - pairs = ts._score_pairs( - [weak, strong], bboxes={}, outlier_crops={}, min_confidence=0.5, close_radius=2, H=100, W=100 - ) - - assert calls == [(3, 4)] # only the strong pair reached the shape step - assert [(p.cell_a, p.cell_b) for p in pairs] == [(3, 4)] - - class TestStitchVisual(PlotTester, metaclass=PlotTesterMeta): _ZOOM = (150, 250, 250, 350) _SEAM_Y = 200