From 5c08081c6902f4fe45f389203f1033be9d1a8d0b Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 2 Apr 2026 13:06:58 +0200 Subject: [PATCH 01/54] init --- src/squidpy/gr/__init__.py | 16 +- src/squidpy/gr/_build.py | 531 +++++++++++++++++++++----- src/squidpy/gr/neighbors.py | 7 + tests/graph/test_spatial_neighbors.py | 38 +- 4 files changed, 505 insertions(+), 87 deletions(-) create mode 100644 src/squidpy/gr/neighbors.py diff --git a/src/squidpy/gr/__init__.py b/src/squidpy/gr/__init__.py index 7bb9bb4a2..9a5b454c8 100644 --- a/src/squidpy/gr/__init__.py +++ b/src/squidpy/gr/__init__.py @@ -2,7 +2,16 @@ from __future__ import annotations -from squidpy.gr._build import SpatialNeighborsResult, mask_graph, spatial_neighbors +from squidpy.gr._build import ( + DelaunayBuilder, + GraphBuilder, + GridBuilder, + KNNBuilder, + RadiusBuilder, + SpatialNeighborsResult, + mask_graph, + spatial_neighbors, +) from squidpy.gr._ligrec import ligrec from squidpy.gr._nhood import ( NhoodEnrichmentResult, @@ -17,6 +26,11 @@ __all__ = [ "SpatialNeighborsResult", + "GraphBuilder", + "KNNBuilder", + "RadiusBuilder", + "DelaunayBuilder", + "GridBuilder", "NhoodEnrichmentResult", "mask_graph", "spatial_neighbors", diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 32630955d..104ad040c 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -3,6 +3,7 @@ from __future__ import annotations import warnings +from abc import ABC, abstractmethod from collections.abc import Iterable from functools import partial from itertools import chain @@ -49,7 +50,15 @@ _save_data, ) -__all__ = ["spatial_neighbors"] +__all__ = [ + "SpatialNeighborsResult", + "GraphBuilder", + "KNNBuilder", + "RadiusBuilder", + "DelaunayBuilder", + "GridBuilder", + "spatial_neighbors", +] class SpatialNeighborsResult(NamedTuple): @@ -59,6 +68,358 @@ class SpatialNeighborsResult(NamedTuple): distances: csr_matrix +class GraphBuilder(ABC): + """Base class for spatial graph construction strategies.""" + + def __init__( + self, + transform: str | Transform | None = None, + set_diag: bool = False, + percentile: float | None = None, + ) -> None: + self.transform = Transform.NONE if transform is None else Transform(transform) + self.set_diag = set_diag + self.percentile = percentile + + @property + @abstractmethod + def coord_type(self) -> CoordType: + """Coordinate system supported by this builder.""" + + @property + def legacy_params(self) -> dict[str, Any]: + """Return parameters expressed in the legacy spatial_neighbors API.""" + return { + "coord_type": self.coord_type, + "n_neighs": 6, + "radius": None, + "delaunay": False, + "n_rings": 1, + "percentile": self.percentile, + "transform": self.transform, + "set_diag": self.set_diag, + } + + @property + def metadata(self) -> dict[str, Any]: + """Return metadata stored in adata.uns after graph construction.""" + params = self.legacy_params + return { + "n_neighbors": params["n_neighs"], + "coord_type": self.coord_type.v, + "radius": params["radius"], + "transform": self.transform.v, + } + + def build(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", SparseEfficiencyWarning) + Adj, Dst = self._build_graph(coords) + + self._apply_filters(Adj, Dst) + self._apply_percentile(Adj, Dst) + Adj.eliminate_zeros() + Dst.eliminate_zeros() + + return self._apply_transform(Adj), Dst + + @abstractmethod + def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: + """Construct raw adjacency and distance matrices.""" + + def _apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: + """Apply builder-specific post-processing filters.""" + return None + + def _apply_percentile(self, Adj: csr_matrix, Dst: csr_matrix) -> None: + if self.percentile is not None and self.coord_type == CoordType.GENERIC: + threshold = np.percentile(Dst.data, self.percentile) + Adj[Dst > threshold] = 0.0 + Dst[Dst > threshold] = 0.0 + + def _apply_transform(self, Adj: csr_matrix) -> csr_matrix: + if self.transform == Transform.SPECTRAL: + return cast(csr_matrix, _transform_a_spectral(Adj)) + if self.transform == Transform.COSINE: + return cast(csr_matrix, _transform_a_cosine(Adj)) + if self.transform == Transform.NONE: + return Adj + + raise NotImplementedError(f"Transform `{self.transform}` is not yet implemented.") + + +class KNNBuilder(GraphBuilder): + """Build a generic k-nearest-neighbor spatial graph.""" + + def __init__( + self, + n_neighs: int = 6, + transform: str | Transform | None = None, + set_diag: bool = False, + percentile: float | None = None, + ) -> None: + assert_positive(n_neighs, name="n_neighs") + super().__init__(transform=transform, set_diag=set_diag, percentile=percentile) + self.n_neighs = n_neighs + + @property + def coord_type(self) -> CoordType: + return CoordType.GENERIC + + @property + def legacy_params(self) -> dict[str, Any]: + return super().legacy_params | {"n_neighs": self.n_neighs} + + def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: + return cast( + tuple[csr_matrix, csr_matrix], + _build_connectivity( + coords, + n_neighs=self.n_neighs, + return_distance=True, + set_diag=self.set_diag, + ), + ) + + +class RadiusBuilder(GraphBuilder): + """Build a generic radius-based spatial graph.""" + + def __init__( + self, + radius: float | tuple[float, float], + n_neighs: int = 6, + transform: str | Transform | None = None, + set_diag: bool = False, + percentile: float | None = None, + ) -> None: + assert_positive(n_neighs, name="n_neighs") + super().__init__(transform=transform, set_diag=set_diag, percentile=percentile) + self.radius = radius + self.n_neighs = n_neighs + + @property + def coord_type(self) -> CoordType: + return CoordType.GENERIC + + @property + def legacy_params(self) -> dict[str, Any]: + return super().legacy_params | {"n_neighs": self.n_neighs, "radius": self.radius} + + def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: + return cast( + tuple[csr_matrix, csr_matrix], + _build_connectivity( + coords, + n_neighs=self.n_neighs, + radius=self.radius, + return_distance=True, + set_diag=self.set_diag, + ), + ) + + def _apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: + if isinstance(self.radius, Iterable): + _filter_by_radius_interval(Adj, Dst, self.radius) + + +class DelaunayBuilder(GraphBuilder): + """Build a generic spatial graph from a Delaunay triangulation.""" + + def __init__( + self, + radius: float | tuple[float, float] | None = None, + n_neighs: int = 6, + transform: str | Transform | None = None, + set_diag: bool = False, + percentile: float | None = None, + ) -> None: + assert_positive(n_neighs, name="n_neighs") + super().__init__(transform=transform, set_diag=set_diag, percentile=percentile) + self.radius = radius + self.n_neighs = n_neighs + + @property + def coord_type(self) -> CoordType: + return CoordType.GENERIC + + @property + def legacy_params(self) -> dict[str, Any]: + return super().legacy_params | {"n_neighs": self.n_neighs, "radius": self.radius, "delaunay": True} + + def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: + return cast( + tuple[csr_matrix, csr_matrix], + _build_connectivity( + coords, + n_neighs=self.n_neighs, + radius=self.radius, + delaunay=True, + return_distance=True, + set_diag=self.set_diag, + ), + ) + + def _apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: + if isinstance(self.radius, Iterable): + _filter_by_radius_interval(Adj, Dst, self.radius) + + +class GridBuilder(GraphBuilder): + """Build a grid-based spatial graph.""" + + def __init__( + self, + n_neighs: int = 6, + n_rings: int = 1, + delaunay: bool = False, + transform: str | Transform | None = None, + set_diag: bool = False, + percentile: float | None = None, + ) -> None: + assert_positive(n_neighs, name="n_neighs") + assert_positive(n_rings, name="n_rings") + super().__init__(transform=transform, set_diag=set_diag, percentile=percentile) + self.n_neighs = n_neighs + self.n_rings = n_rings + self.delaunay = delaunay + + @property + def coord_type(self) -> CoordType: + return CoordType.GRID + + @property + def legacy_params(self) -> dict[str, Any]: + return super().legacy_params | { + "n_neighs": self.n_neighs, + "delaunay": self.delaunay, + "n_rings": self.n_rings, + } + + def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: + return _build_grid( + coords, + n_neighs=self.n_neighs, + n_rings=self.n_rings, + delaunay=self.delaunay, + set_diag=self.set_diag, + ) + + +def _filter_by_radius_interval( + Adj: csr_matrix, + Dst: csr_matrix, + radius: Iterable[float], +) -> None: + minn, maxx = sorted(radius)[:2] + mask = (Dst.data < minn) | (Dst.data > maxx) + a_diag = Adj.diagonal() + + Dst.data[mask] = 0.0 + Adj.data[mask] = 0.0 + Adj.setdiag(a_diag) + + +def _normalize_builder_param(name: str, value: Any) -> Any: + if name == "coord_type": + return None if value is None else CoordType(value) + if name == "transform": + return Transform.NONE if value is None else Transform(value) + if name == "radius" and isinstance(value, tuple): + return tuple(sorted(value)[:2]) + + return value + + +def _validate_builder_compatibility( + builder: GraphBuilder, + *, + coord_type: str | CoordType | None, + n_neighs: int, + radius: float | tuple[float, float] | None, + delaunay: bool, + n_rings: int, + percentile: float | None, + transform: str | Transform | None, + set_diag: bool, +) -> None: + defaults = { + "coord_type": None, + "n_neighs": 6, + "radius": None, + "delaunay": False, + "n_rings": 1, + "percentile": None, + "transform": None, + "set_diag": False, + } + provided = { + "coord_type": coord_type, + "n_neighs": n_neighs, + "radius": radius, + "delaunay": delaunay, + "n_rings": n_rings, + "percentile": percentile, + "transform": transform, + "set_diag": set_diag, + } + + for key, value in provided.items(): + if value == defaults[key]: + continue + + if _normalize_builder_param(key, value) != _normalize_builder_param(key, builder.legacy_params[key]): + raise ValueError( + f"Parameter `{key}` conflicts with `{type(builder).__name__}`. " + "Leave graph-construction arguments at their defaults or make them match the builder." + ) + + +def _resolve_graph_builder( + *, + coord_type: CoordType, + n_neighs: int, + radius: float | tuple[float, float] | None, + delaunay: bool, + n_rings: int, + percentile: float | None, + transform: Transform, + set_diag: bool, +) -> GraphBuilder: + if coord_type == CoordType.GRID: + return GridBuilder( + n_neighs=n_neighs, + n_rings=n_rings, + delaunay=delaunay, + transform=transform, + set_diag=set_diag, + percentile=percentile, + ) + if delaunay: + return DelaunayBuilder( + radius=radius, + n_neighs=n_neighs, + transform=transform, + set_diag=set_diag, + percentile=percentile, + ) + if radius is not None: + return RadiusBuilder( + radius=radius, + n_neighs=n_neighs, + transform=transform, + set_diag=set_diag, + percentile=percentile, + ) + + return KNNBuilder( + n_neighs=n_neighs, + transform=transform, + set_diag=set_diag, + percentile=percentile, + ) + + @d.dedent @inject_docs(t=Transform, c=CoordType) def spatial_neighbors( @@ -75,6 +436,7 @@ def spatial_neighbors( percentile: float | None = None, transform: str | Transform | None = None, set_diag: bool = False, + builder: GraphBuilder | None = None, key_added: str = "spatial", copy: bool = False, ) -> SpatialNeighborsResult | None: @@ -128,10 +490,47 @@ def spatial_neighbors( - `{t.NONE.v}` - no transformation of the adjacency matrix. set_diag Whether to set the diagonal of the spatial connectivities to `1.0`. + builder + Advanced graph construction strategy. When provided, graph-construction arguments + (``coord_type``, ``n_neighs``, ``radius``, ``delaunay``, ``n_rings``, ``percentile``, + ``transform``, ``set_diag``) must either be left at their defaults or match the builder. key_added Key which controls where the results are saved if ``copy = False``. %(copy)s + Notes + ----- + ``spatial_neighbors`` has 4 graph-construction modes: + + - Grid mode: + ``coord_type='grid'``. Uses ``n_neighs`` and ``n_rings``. + ``radius`` is ignored. ``delaunay`` is forwarded to the + underlying grid connectivity builder. This is the mode used + for Visium-like grid coordinates. + - Generic k-nearest-neighbor mode: + ``coord_type='generic'``, ``delaunay=False``, ``radius=None``. + Uses ``n_neighs``. + - Generic radius mode: + ``coord_type='generic'``, ``delaunay=False``, ``radius`` set. + Uses ``radius`` and builds a radius-based neighbor graph. + If ``radius`` is a tuple, the graph is built with the maximum + radius and then pruned to the interval + ``[min(radius), max(radius)]``. + - Generic Delaunay mode: + ``coord_type='generic'``, ``delaunay=True``. + Builds a Delaunay triangulation graph. ``n_neighs`` is not + used for the triangulation itself, but is still accepted for + backward compatibility. If ``radius`` is a tuple, it is used + only as a post-construction pruning interval. + + Across these modes: + + - ``percentile`` only affects generic graphs. + - ``transform`` and ``set_diag`` apply to all modes. + - If ``builder`` is provided, it determines the mode directly. + The legacy graph-construction arguments must then stay at + their defaults or match the builder configuration. + Returns ------- If ``copy = True``, returns a :class:`~squidpy.gr.SpatialNeighborsResult` with the spatial connectivities and distances matrices. @@ -191,20 +590,47 @@ def spatial_neighbors( adata = adata.tables[table_key] library_key = region_key - assert_positive(n_rings, name="n_rings") - assert_positive(n_neighs, name="n_neighs") _assert_spatial_basis(adata, spatial_key) - transform = Transform.NONE if transform is None else Transform(transform) - if coord_type is None: - if radius is not None: - logg.warning( - f"Graph creation with `radius` is only available when `coord_type = {CoordType.GENERIC!r}` specified. " - f"Ignoring parameter `radius = {radius}`." - ) - coord_type = CoordType.GRID if Key.uns.spatial in adata.uns else CoordType.GENERIC + if builder is not None: + if not isinstance(builder, GraphBuilder): + raise TypeError(f"Expected `builder` to be a `GraphBuilder`, found `{type(builder)}`.") + _validate_builder_compatibility( + builder, + coord_type=coord_type, + n_neighs=n_neighs, + radius=radius, + delaunay=delaunay, + n_rings=n_rings, + percentile=percentile, + transform=transform, + set_diag=set_diag, + ) else: - coord_type = CoordType(coord_type) + assert_positive(n_rings, name="n_rings") + assert_positive(n_neighs, name="n_neighs") + + transform = Transform.NONE if transform is None else Transform(transform) + if coord_type is None: + if radius is not None: + logg.warning( + f"Graph creation with `radius` is only available when `coord_type = {CoordType.GENERIC!r}` specified. " + f"Ignoring parameter `radius = {radius}`." + ) + coord_type = CoordType.GRID if Key.uns.spatial in adata.uns else CoordType.GENERIC + else: + coord_type = CoordType(coord_type) + + builder = _resolve_graph_builder( + coord_type=coord_type, + n_neighs=n_neighs, + radius=radius, + delaunay=delaunay, + n_rings=n_rings, + percentile=percentile, + transform=transform, + set_diag=set_diag, + ) if library_key is not None: _assert_categorical_obs(adata, key=library_key) @@ -214,19 +640,12 @@ def spatial_neighbors( libs = [None] start = logg.info( - f"Creating graph using `{coord_type}` coordinates and `{transform}` transform and `{len(libs)}` libraries." + f"Creating graph using `{builder.coord_type}` coordinates and `{builder.transform}` transform and `{len(libs)}` libraries." ) _build_fun = partial( _spatial_neighbor, spatial_key=spatial_key, - coord_type=coord_type, - n_neighs=n_neighs, - radius=radius, - delaunay=delaunay, - n_rings=n_rings, - transform=transform, - set_diag=set_diag, - percentile=percentile, + builder=builder, ) if library_key is not None: @@ -248,12 +667,7 @@ def spatial_neighbors( neighbors_dict = { "connectivities_key": conns_key, "distances_key": dists_key, - "params": { - "n_neighbors": n_neighs, - "coord_type": coord_type.v, - "radius": radius, - "transform": transform.v, - }, + "params": builder.metadata, } if copy: @@ -267,66 +681,13 @@ def spatial_neighbors( def _spatial_neighbor( adata: AnnData, spatial_key: str = Key.obsm.spatial, - coord_type: str | CoordType | None = None, - n_neighs: int = 6, - radius: float | tuple[float, float] | None = None, - delaunay: bool = False, - n_rings: int = 1, - transform: str | Transform | None = None, - set_diag: bool = False, - percentile: float | None = None, + builder: GraphBuilder | None = None, ) -> tuple[csr_matrix, csr_matrix]: - coords = adata.obsm[spatial_key] - with warnings.catch_warnings(): - warnings.simplefilter("ignore", SparseEfficiencyWarning) - if coord_type == CoordType.GRID: - Adj, Dst = _build_grid( - coords, - n_neighs=n_neighs, - n_rings=n_rings, - delaunay=delaunay, - set_diag=set_diag, - ) - elif coord_type == CoordType.GENERIC: - Adj, Dst = _build_connectivity( - coords, - n_neighs=n_neighs, - radius=radius, - delaunay=delaunay, - return_distance=True, - set_diag=set_diag, - ) - else: - raise NotImplementedError(f"Coordinate type `{coord_type}` is not yet implemented.") - - if coord_type == CoordType.GENERIC and isinstance(radius, Iterable): - minn, maxx = sorted(radius)[:2] - mask = (Dst.data < minn) | (Dst.data > maxx) - a_diag = Adj.diagonal() - - Dst.data[mask] = 0.0 - Adj.data[mask] = 0.0 - Adj.setdiag(a_diag) - - if percentile is not None and coord_type == CoordType.GENERIC: - threshold = np.percentile(Dst.data, percentile) - Adj[Dst > threshold] = 0.0 - Dst[Dst > threshold] = 0.0 - - Adj.eliminate_zeros() - Dst.eliminate_zeros() - - # check transform - if transform == Transform.SPECTRAL: - Adj = _transform_a_spectral(Adj) - elif transform == Transform.COSINE: - Adj = _transform_a_cosine(Adj) - elif transform == Transform.NONE: - pass - else: - raise NotImplementedError(f"Transform `{transform}` is not yet implemented.") + if builder is None: + raise ValueError("No graph builder was provided.") - return Adj, Dst + coords = adata.obsm[spatial_key] + return builder.build(coords) def _build_grid( diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py new file mode 100644 index 000000000..3e9d1d14c --- /dev/null +++ b/src/squidpy/gr/neighbors.py @@ -0,0 +1,7 @@ +"""Public builder API for spatial neighbor graph construction.""" + +from __future__ import annotations + +from squidpy.gr._build import DelaunayBuilder, GraphBuilder, GridBuilder, KNNBuilder, RadiusBuilder + +__all__ = ["GraphBuilder", "KNNBuilder", "RadiusBuilder", "DelaunayBuilder", "GridBuilder"] diff --git a/tests/graph/test_spatial_neighbors.py b/tests/graph/test_spatial_neighbors.py index db638132b..57b6528c0 100644 --- a/tests/graph/test_spatial_neighbors.py +++ b/tests/graph/test_spatial_neighbors.py @@ -11,8 +11,9 @@ from spatialdata.datasets import blobs from squidpy._constants._pkg_constants import Key -from squidpy.gr import mask_graph, spatial_neighbors +from squidpy.gr import DelaunayBuilder, GridBuilder, KNNBuilder, RadiusBuilder, mask_graph, spatial_neighbors from squidpy.gr._build import _build_connectivity +from squidpy.gr.neighbors import KNNBuilder as PublicKNNBuilder class TestSpatialNeighbors: @@ -213,6 +214,41 @@ def test_copy(self, non_visium_adata: AnnData): np.testing.assert_allclose(result.distances.toarray(), self._gt_ddist) np.testing.assert_allclose(result.connectivities.toarray(), self._gt_dgraph) + def test_builder_module_export(self): + assert PublicKNNBuilder is KNNBuilder + + def test_knn_builder_matches_legacy(self, non_visium_adata: AnnData): + legacy = spatial_neighbors(non_visium_adata, n_neighs=3, coord_type="generic", copy=True) + builder = spatial_neighbors(non_visium_adata, builder=KNNBuilder(n_neighs=3), copy=True) + + np.testing.assert_array_equal(legacy.connectivities.toarray(), builder.connectivities.toarray()) + np.testing.assert_allclose(legacy.distances.toarray(), builder.distances.toarray()) + + def test_radius_builder_matches_legacy(self, non_visium_adata: AnnData): + legacy = spatial_neighbors(non_visium_adata, radius=5.0, coord_type="generic", copy=True) + builder = spatial_neighbors(non_visium_adata, builder=RadiusBuilder(radius=5.0), copy=True) + + np.testing.assert_array_equal(legacy.connectivities.toarray(), builder.connectivities.toarray()) + np.testing.assert_allclose(legacy.distances.toarray(), builder.distances.toarray()) + + def test_delaunay_builder_matches_legacy(self, non_visium_adata: AnnData): + legacy = spatial_neighbors(non_visium_adata, delaunay=True, coord_type="generic", copy=True) + builder = spatial_neighbors(non_visium_adata, builder=DelaunayBuilder(), copy=True) + + np.testing.assert_array_equal(legacy.connectivities.toarray(), builder.connectivities.toarray()) + np.testing.assert_allclose(legacy.distances.toarray(), builder.distances.toarray()) + + def test_grid_builder_matches_legacy(self, adata_squaregrid: AnnData): + legacy = spatial_neighbors(adata_squaregrid, n_neighs=4, n_rings=2, coord_type="grid", copy=True) + builder = spatial_neighbors(adata_squaregrid, builder=GridBuilder(n_neighs=4, n_rings=2), copy=True) + + np.testing.assert_array_equal(legacy.connectivities.toarray(), builder.connectivities.toarray()) + np.testing.assert_allclose(legacy.distances.toarray(), builder.distances.toarray()) + + def test_builder_argument_conflict(self, non_visium_adata: AnnData): + with pytest.raises(ValueError, match="conflicts"): + spatial_neighbors(non_visium_adata, builder=RadiusBuilder(radius=5.0), delaunay=True) + @pytest.mark.parametrize("percentile", [99.0, 95.0]) def test_percentile_filtering(self, adata_hne: AnnData, percentile: float, coord_type="generic"): result = spatial_neighbors(adata_hne, coord_type=coord_type, copy=True) From 5847d8406b26ad1cf79ab9fcc342cb6c3a228c96 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 2 Apr 2026 13:11:33 +0200 Subject: [PATCH 02/54] doc clarifications --- src/squidpy/gr/_build.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 104ad040c..25f9de2c0 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -530,6 +530,39 @@ def spatial_neighbors( - If ``builder`` is provided, it determines the mode directly. The legacy graph-construction arguments must then stay at their defaults or match the builder configuration. + - By default, observations are not treated as their own + neighbors. The distance matrix always has a zero diagonal. + The connectivity matrix only gets a nonzero diagonal when + ``set_diag=True``. + + Argument precedence + ------------------- + When ``builder`` is not provided, the mode is resolved as follows: + + - If ``coord_type`` resolves to ``'grid'``, grid mode is used. + In that case ``radius`` is ignored. + - Otherwise, if ``delaunay=True``, Delaunay mode is used. + In this mode ``n_neighs`` does not affect the triangulation. + A tuple ``radius`` is only used afterward as a pruning + interval. A scalar ``radius`` is ignored. + - Otherwise, if ``radius`` is set, radius mode is used. + In this mode ``n_neighs`` does not act as a second cutoff. + - Otherwise, k-nearest-neighbor mode is used. + + Grid-specific behavior + ---------------------- + Grid mode currently does not validate ``n_neighs`` to a fixed set + such as ``{4, 6}``. Internally it first queries the + ``n_neighs`` nearest candidates and then applies a distance-based + correction tuned for grid-like coordinates. As a result: + + - values such as ``n_neighs=4`` and ``n_neighs=6`` are the + intended square-grid and hex-grid choices, respectively; + - other values are accepted for backward compatibility, but + their geometric interpretation is not guaranteed to match a + continuous ring on the grid; + - no clockwise or other within-ring ordering is part of the + public API. Returns ------- From 179ba06a0ae6d54eb7029c4b4d167b780ba05367 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 2 Apr 2026 13:35:54 +0200 Subject: [PATCH 03/54] don't expose the builders --- src/squidpy/gr/__init__.py | 10 ---------- src/squidpy/gr/_build.py | 9 ++++++--- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/squidpy/gr/__init__.py b/src/squidpy/gr/__init__.py index 9a5b454c8..d4f39958e 100644 --- a/src/squidpy/gr/__init__.py +++ b/src/squidpy/gr/__init__.py @@ -3,11 +3,6 @@ from __future__ import annotations from squidpy.gr._build import ( - DelaunayBuilder, - GraphBuilder, - GridBuilder, - KNNBuilder, - RadiusBuilder, SpatialNeighborsResult, mask_graph, spatial_neighbors, @@ -26,11 +21,6 @@ __all__ = [ "SpatialNeighborsResult", - "GraphBuilder", - "KNNBuilder", - "RadiusBuilder", - "DelaunayBuilder", - "GridBuilder", "NhoodEnrichmentResult", "mask_graph", "spatial_neighbors", diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 25f9de2c0..3823c22f7 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -275,11 +275,10 @@ def __init__( delaunay: bool = False, transform: str | Transform | None = None, set_diag: bool = False, - percentile: float | None = None, ) -> None: assert_positive(n_neighs, name="n_neighs") assert_positive(n_rings, name="n_rings") - super().__init__(transform=transform, set_diag=set_diag, percentile=percentile) + super().__init__(transform=transform, set_diag=set_diag, percentile=None) self.n_neighs = n_neighs self.n_rings = n_rings self.delaunay = delaunay @@ -387,13 +386,17 @@ def _resolve_graph_builder( set_diag: bool, ) -> GraphBuilder: if coord_type == CoordType.GRID: + if percentile is not None: + raise ValueError( + "`percentile` is not supported for grid coordinates. " + "It only applies to generic (non-grid) graphs." + ) return GridBuilder( n_neighs=n_neighs, n_rings=n_rings, delaunay=delaunay, transform=transform, set_diag=set_diag, - percentile=percentile, ) if delaunay: return DelaunayBuilder( From e187abafd210691740163c04d5639445eb878b0d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:37:14 +0000 Subject: [PATCH 04/54] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/squidpy/gr/_build.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 3823c22f7..b7bea6dfa 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -388,8 +388,7 @@ def _resolve_graph_builder( if coord_type == CoordType.GRID: if percentile is not None: raise ValueError( - "`percentile` is not supported for grid coordinates. " - "It only applies to generic (non-grid) graphs." + "`percentile` is not supported for grid coordinates. It only applies to generic (non-grid) graphs." ) return GridBuilder( n_neighs=n_neighs, From cdb569923c259dbb720d71816c9363236afeb65d Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 2 Apr 2026 13:50:49 +0200 Subject: [PATCH 05/54] deduplicate --- src/squidpy/gr/_build.py | 37 +++--- tests/graph/test_spatial_neighbors.py | 183 ++++++++++++++++---------- 2 files changed, 133 insertions(+), 87 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 3823c22f7..be724e369 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -182,7 +182,22 @@ def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: ) -class RadiusBuilder(GraphBuilder): +class _RadiusFilterBuilder(GraphBuilder): + """Intermediate base for builders that support radius-interval pruning.""" + + radius: float | tuple[float, float] | None + n_neighs: int + + @property + def coord_type(self) -> CoordType: + return CoordType.GENERIC + + def _apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: + if isinstance(self.radius, Iterable): + _filter_by_radius_interval(Adj, Dst, self.radius) + + +class RadiusBuilder(_RadiusFilterBuilder): """Build a generic radius-based spatial graph.""" def __init__( @@ -198,10 +213,6 @@ def __init__( self.radius = radius self.n_neighs = n_neighs - @property - def coord_type(self) -> CoordType: - return CoordType.GENERIC - @property def legacy_params(self) -> dict[str, Any]: return super().legacy_params | {"n_neighs": self.n_neighs, "radius": self.radius} @@ -218,12 +229,8 @@ def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: ), ) - def _apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: - if isinstance(self.radius, Iterable): - _filter_by_radius_interval(Adj, Dst, self.radius) - -class DelaunayBuilder(GraphBuilder): +class DelaunayBuilder(_RadiusFilterBuilder): """Build a generic spatial graph from a Delaunay triangulation.""" def __init__( @@ -239,10 +246,6 @@ def __init__( self.radius = radius self.n_neighs = n_neighs - @property - def coord_type(self) -> CoordType: - return CoordType.GENERIC - @property def legacy_params(self) -> dict[str, Any]: return super().legacy_params | {"n_neighs": self.n_neighs, "radius": self.radius, "delaunay": True} @@ -260,10 +263,6 @@ def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: ), ) - def _apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: - if isinstance(self.radius, Iterable): - _filter_by_radius_interval(Adj, Dst, self.radius) - class GridBuilder(GraphBuilder): """Build a grid-based spatial graph.""" @@ -555,7 +554,7 @@ def spatial_neighbors( Grid-specific behavior ---------------------- Grid mode currently does not validate ``n_neighs`` to a fixed set - such as ``{4, 6}``. Internally it first queries the + such as ``{{4, 6}}``. Internally it first queries the ``n_neighs`` nearest candidates and then applies a distance-based correction tuned for grid-like coordinates. As a result: diff --git a/tests/graph/test_spatial_neighbors.py b/tests/graph/test_spatial_neighbors.py index 57b6528c0..b99c7c751 100644 --- a/tests/graph/test_spatial_neighbors.py +++ b/tests/graph/test_spatial_neighbors.py @@ -10,6 +10,7 @@ from shapely import Point from spatialdata.datasets import blobs +from squidpy._constants._constants import Transform from squidpy._constants._pkg_constants import Key from squidpy.gr import DelaunayBuilder, GridBuilder, KNNBuilder, RadiusBuilder, mask_graph, spatial_neighbors from squidpy.gr._build import _build_connectivity @@ -47,6 +48,22 @@ def _adata_concat(adata1, adata2): ) return adata_concat, batch1, batch2 + @staticmethod + def _assert_library_key_block_diagonal(adata, **neighbor_kwargs): + adata2 = adata.copy() + adata_concat, batch1, batch2 = TestSpatialNeighbors._adata_concat(adata, adata2) + spatial_neighbors(adata2, **neighbor_kwargs) + spatial_neighbors(adata_concat, library_key="library_id", **neighbor_kwargs) + np.testing.assert_array_equal( + adata_concat[adata_concat.obs["library_id"] == batch1].obsp[Key.obsp.spatial_conn()].toarray(), + adata.obsp[Key.obsp.spatial_conn()].toarray(), + ) + np.testing.assert_array_equal( + adata_concat[adata_concat.obs["library_id"] == batch2].obsp[Key.obsp.spatial_conn()].toarray(), + adata2.obsp[Key.obsp.spatial_conn()].toarray(), + ) + return adata_concat + # TODO: add edge cases # TODO(giovp): test with reshuffling @pytest.mark.parametrize(("n_rings", "n_neigh", "sum_dist"), [(1, 6, 0), (2, 18, 30), (3, 36, 84)]) @@ -66,20 +83,8 @@ def test_spatial_neighbors_visium( if n_rings > 1: assert visium_adata.obsp[Key.obsp.spatial_dist()][0].sum() == sum_dist - # test for library_key - visium_adata2 = visium_adata.copy() - adata_concat, batch1, batch2 = TestSpatialNeighbors._adata_concat(visium_adata, visium_adata2) - spatial_neighbors(visium_adata2, n_rings=n_rings) - spatial_neighbors(adata_concat, library_key="library_id", n_rings=n_rings) + adata_concat = self._assert_library_key_block_diagonal(visium_adata, n_rings=n_rings) assert adata_concat.obsp[Key.obsp.spatial_conn()][0].sum() == n_neigh - np.testing.assert_array_equal( - adata_concat[adata_concat.obs["library_id"] == batch1].obsp[Key.obsp.spatial_conn()].toarray(), - visium_adata.obsp[Key.obsp.spatial_conn()].toarray(), - ) - np.testing.assert_array_equal( - adata_concat[adata_concat.obs["library_id"] == batch2].obsp[Key.obsp.spatial_conn()].toarray(), - visium_adata2.obsp[Key.obsp.spatial_conn()].toarray(), - ) @pytest.mark.parametrize(("n_rings", "n_neigh", "sum_neigh"), [(1, 4, 4), (2, 4, 12), (3, 4, 24)]) def test_spatial_neighbors_squaregrid(self, adata_squaregrid: AnnData, n_rings: int, n_neigh: int, sum_neigh: int): @@ -91,26 +96,10 @@ def test_spatial_neighbors_squaregrid(self, adata_squaregrid: AnnData, n_rings: assert np.diff(adata.obsp[Key.obsp.spatial_conn()].indptr).max() == sum_neigh assert adata.uns[Key.uns.spatial_neighs()]["distances_key"] == Key.obsp.spatial_dist() - # test for library_key - adata2 = adata.copy() - adata_concat, batch1, batch2 = TestSpatialNeighbors._adata_concat(adata, adata2) - spatial_neighbors(adata2, n_neighs=n_neigh, n_rings=n_rings, coord_type="grid") - spatial_neighbors( - adata_concat, - library_key="library_id", - n_neighs=n_neigh, - n_rings=n_rings, - coord_type="grid", + adata_concat = self._assert_library_key_block_diagonal( + adata, n_neighs=n_neigh, n_rings=n_rings, coord_type="grid", ) assert np.diff(adata_concat.obsp[Key.obsp.spatial_conn()].indptr).max() == sum_neigh - np.testing.assert_array_equal( - adata_concat[adata_concat.obs["library_id"] == batch1].obsp[Key.obsp.spatial_conn()].toarray(), - adata.obsp[Key.obsp.spatial_conn()].toarray(), - ) - np.testing.assert_array_equal( - adata_concat[adata_concat.obs["library_id"] == batch2].obsp[Key.obsp.spatial_conn()].toarray(), - adata2.obsp[Key.obsp.spatial_conn()].toarray(), - ) @pytest.mark.parametrize("type_rings", [("grid", 1), ("grid", 6), ("generic", 1)]) @pytest.mark.parametrize("set_diag", [False, True]) @@ -162,20 +151,7 @@ def test_spatial_neighbors_non_visium(self, non_visium_adata: AnnData): np.testing.assert_array_equal(spatial_graph, self._gt_dgraph) np.testing.assert_allclose(spatial_dist, self._gt_ddist) - # test for library_key - non_visium_adata2 = non_visium_adata.copy() - adata_concat, batch1, batch2 = TestSpatialNeighbors._adata_concat(non_visium_adata, non_visium_adata2) - spatial_neighbors(adata_concat, library_key="library_id", delaunay=True, coord_type=None) - spatial_neighbors(non_visium_adata2, delaunay=True, coord_type=None) - - np.testing.assert_array_equal( - adata_concat[adata_concat.obs["library_id"] == batch1].obsp[Key.obsp.spatial_conn()].toarray(), - non_visium_adata.obsp[Key.obsp.spatial_conn()].toarray(), - ) - np.testing.assert_array_equal( - adata_concat[adata_concat.obs["library_id"] == batch2].obsp[Key.obsp.spatial_conn()].toarray(), - non_visium_adata2.obsp[Key.obsp.spatial_conn()].toarray(), - ) + self._assert_library_key_block_diagonal(non_visium_adata, delaunay=True, coord_type=None) @pytest.mark.parametrize("set_diag", [False, True]) @pytest.mark.parametrize("radius", [(0, np.inf), (2.0, 4.0), (-42, -420), (100, 200)]) @@ -217,37 +193,108 @@ def test_copy(self, non_visium_adata: AnnData): def test_builder_module_export(self): assert PublicKNNBuilder is KNNBuilder - def test_knn_builder_matches_legacy(self, non_visium_adata: AnnData): - legacy = spatial_neighbors(non_visium_adata, n_neighs=3, coord_type="generic", copy=True) - builder = spatial_neighbors(non_visium_adata, builder=KNNBuilder(n_neighs=3), copy=True) + @pytest.mark.parametrize( + ("legacy_kwargs", "builder"), + [ + ({"n_neighs": 3, "coord_type": "generic"}, KNNBuilder(n_neighs=3)), + ({"radius": 5.0, "coord_type": "generic"}, RadiusBuilder(radius=5.0)), + ({"delaunay": True, "coord_type": "generic"}, DelaunayBuilder()), + ], + ids=["knn", "radius", "delaunay"], + ) + def test_generic_builder_matches_legacy( + self, non_visium_adata: AnnData, legacy_kwargs: dict, builder: object + ): + legacy = spatial_neighbors(non_visium_adata, **legacy_kwargs, copy=True) + result = spatial_neighbors(non_visium_adata, builder=builder, copy=True) - np.testing.assert_array_equal(legacy.connectivities.toarray(), builder.connectivities.toarray()) - np.testing.assert_allclose(legacy.distances.toarray(), builder.distances.toarray()) + np.testing.assert_array_equal(legacy.connectivities.toarray(), result.connectivities.toarray()) + np.testing.assert_allclose(legacy.distances.toarray(), result.distances.toarray()) - def test_radius_builder_matches_legacy(self, non_visium_adata: AnnData): - legacy = spatial_neighbors(non_visium_adata, radius=5.0, coord_type="generic", copy=True) - builder = spatial_neighbors(non_visium_adata, builder=RadiusBuilder(radius=5.0), copy=True) + @pytest.mark.parametrize( + ("legacy_kwargs", "builder"), + [ + ({"n_neighs": 4, "n_rings": 2, "coord_type": "grid"}, GridBuilder(n_neighs=4, n_rings=2)), + ({"n_neighs": 6, "n_rings": 1, "coord_type": "grid"}, GridBuilder(n_neighs=6, n_rings=1)), + ], + ids=["4neighs_2rings", "6neighs_1ring"], + ) + def test_grid_builder_matches_legacy( + self, adata_squaregrid: AnnData, legacy_kwargs: dict, builder: object + ): + legacy = spatial_neighbors(adata_squaregrid, **legacy_kwargs, copy=True) + result = spatial_neighbors(adata_squaregrid, builder=builder, copy=True) - np.testing.assert_array_equal(legacy.connectivities.toarray(), builder.connectivities.toarray()) - np.testing.assert_allclose(legacy.distances.toarray(), builder.distances.toarray()) + np.testing.assert_array_equal(legacy.connectivities.toarray(), result.connectivities.toarray()) + np.testing.assert_allclose(legacy.distances.toarray(), result.distances.toarray()) - def test_delaunay_builder_matches_legacy(self, non_visium_adata: AnnData): - legacy = spatial_neighbors(non_visium_adata, delaunay=True, coord_type="generic", copy=True) - builder = spatial_neighbors(non_visium_adata, builder=DelaunayBuilder(), copy=True) + def test_builder_argument_conflict(self, non_visium_adata: AnnData): + with pytest.raises(ValueError, match="conflicts"): + spatial_neighbors(non_visium_adata, builder=RadiusBuilder(radius=5.0), delaunay=True) - np.testing.assert_array_equal(legacy.connectivities.toarray(), builder.connectivities.toarray()) - np.testing.assert_allclose(legacy.distances.toarray(), builder.distances.toarray()) + def test_builder_matching_non_default_legacy_args(self, non_visium_adata: AnnData): + builder = RadiusBuilder(radius=5.0, n_neighs=3, percentile=95.0, transform="cosine", set_diag=True) - def test_grid_builder_matches_legacy(self, adata_squaregrid: AnnData): - legacy = spatial_neighbors(adata_squaregrid, n_neighs=4, n_rings=2, coord_type="grid", copy=True) - builder = spatial_neighbors(adata_squaregrid, builder=GridBuilder(n_neighs=4, n_rings=2), copy=True) + baseline = spatial_neighbors(non_visium_adata, builder=builder, copy=True) + matched = spatial_neighbors( + non_visium_adata, + builder=builder, + coord_type="generic", + n_neighs=3, + radius=5.0, + percentile=95.0, + transform=Transform.COSINE, + set_diag=True, + copy=True, + ) - np.testing.assert_array_equal(legacy.connectivities.toarray(), builder.connectivities.toarray()) - np.testing.assert_allclose(legacy.distances.toarray(), builder.distances.toarray()) + np.testing.assert_allclose(baseline.connectivities.toarray(), matched.connectivities.toarray()) + np.testing.assert_allclose(baseline.distances.toarray(), matched.distances.toarray()) - def test_builder_argument_conflict(self, non_visium_adata: AnnData): - with pytest.raises(ValueError, match="conflicts"): - spatial_neighbors(non_visium_adata, builder=RadiusBuilder(radius=5.0), delaunay=True) + def test_builder_compatibility_normalizes_none_transform(self, non_visium_adata: AnnData): + builder = KNNBuilder(n_neighs=3, transform=Transform.NONE) + + baseline = spatial_neighbors(non_visium_adata, builder=builder, copy=True) + matched = spatial_neighbors(non_visium_adata, builder=builder, transform=None, copy=True) + + np.testing.assert_array_equal(baseline.connectivities.toarray(), matched.connectivities.toarray()) + np.testing.assert_allclose(baseline.distances.toarray(), matched.distances.toarray()) + + def test_builder_compatibility_normalizes_tuple_radius_order(self, non_visium_adata: AnnData): + builder = RadiusBuilder(radius=(4.0, 2.0)) + + baseline = spatial_neighbors(non_visium_adata, builder=builder, copy=True) + matched = spatial_neighbors( + non_visium_adata, + builder=builder, + coord_type="generic", + radius=(2.0, 4.0), + copy=True, + ) + + np.testing.assert_array_equal(baseline.connectivities.toarray(), matched.connectivities.toarray()) + np.testing.assert_allclose(baseline.distances.toarray(), matched.distances.toarray()) + + def test_grid_mode_ignores_radius(self, adata_squaregrid: AnnData): + default = spatial_neighbors(adata_squaregrid, coord_type="grid", n_neighs=4, n_rings=2, copy=True) + ignored = spatial_neighbors( + adata_squaregrid, + coord_type="grid", + n_neighs=4, + n_rings=2, + radius=(0.1, 0.2), + copy=True, + ) + + np.testing.assert_array_equal(default.connectivities.toarray(), ignored.connectivities.toarray()) + np.testing.assert_allclose(default.distances.toarray(), ignored.distances.toarray()) + + def test_delaunay_mode_ignores_scalar_radius(self, non_visium_adata: AnnData): + default = spatial_neighbors(non_visium_adata, coord_type="generic", delaunay=True, copy=True) + ignored = spatial_neighbors(non_visium_adata, coord_type="generic", delaunay=True, radius=5.0, copy=True) + + np.testing.assert_array_equal(default.connectivities.toarray(), ignored.connectivities.toarray()) + np.testing.assert_allclose(default.distances.toarray(), ignored.distances.toarray()) @pytest.mark.parametrize("percentile", [99.0, 95.0]) def test_percentile_filtering(self, adata_hne: AnnData, percentile: float, coord_type="generic"): From 69cda1acbe05444be285b9b471de60357470ca74 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:51:15 +0000 Subject: [PATCH 06/54] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/graph/test_spatial_neighbors.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/graph/test_spatial_neighbors.py b/tests/graph/test_spatial_neighbors.py index b99c7c751..be1d897e7 100644 --- a/tests/graph/test_spatial_neighbors.py +++ b/tests/graph/test_spatial_neighbors.py @@ -97,7 +97,10 @@ def test_spatial_neighbors_squaregrid(self, adata_squaregrid: AnnData, n_rings: assert adata.uns[Key.uns.spatial_neighs()]["distances_key"] == Key.obsp.spatial_dist() adata_concat = self._assert_library_key_block_diagonal( - adata, n_neighs=n_neigh, n_rings=n_rings, coord_type="grid", + adata, + n_neighs=n_neigh, + n_rings=n_rings, + coord_type="grid", ) assert np.diff(adata_concat.obsp[Key.obsp.spatial_conn()].indptr).max() == sum_neigh @@ -202,9 +205,7 @@ def test_builder_module_export(self): ], ids=["knn", "radius", "delaunay"], ) - def test_generic_builder_matches_legacy( - self, non_visium_adata: AnnData, legacy_kwargs: dict, builder: object - ): + def test_generic_builder_matches_legacy(self, non_visium_adata: AnnData, legacy_kwargs: dict, builder: object): legacy = spatial_neighbors(non_visium_adata, **legacy_kwargs, copy=True) result = spatial_neighbors(non_visium_adata, builder=builder, copy=True) @@ -219,9 +220,7 @@ def test_generic_builder_matches_legacy( ], ids=["4neighs_2rings", "6neighs_1ring"], ) - def test_grid_builder_matches_legacy( - self, adata_squaregrid: AnnData, legacy_kwargs: dict, builder: object - ): + def test_grid_builder_matches_legacy(self, adata_squaregrid: AnnData, legacy_kwargs: dict, builder: object): legacy = spatial_neighbors(adata_squaregrid, **legacy_kwargs, copy=True) result = spatial_neighbors(adata_squaregrid, builder=builder, copy=True) From fddc027d835485f3e5629b31a3be4e40c0df03bc Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 2 Apr 2026 13:53:06 +0200 Subject: [PATCH 07/54] arrange builders --- src/squidpy/gr/_build.py | 5 ----- tests/graph/test_spatial_neighbors.py | 4 ++-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index d2602cfe1..d4506b8a8 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -52,11 +52,6 @@ __all__ = [ "SpatialNeighborsResult", - "GraphBuilder", - "KNNBuilder", - "RadiusBuilder", - "DelaunayBuilder", - "GridBuilder", "spatial_neighbors", ] diff --git a/tests/graph/test_spatial_neighbors.py b/tests/graph/test_spatial_neighbors.py index b99c7c751..94b70f387 100644 --- a/tests/graph/test_spatial_neighbors.py +++ b/tests/graph/test_spatial_neighbors.py @@ -12,8 +12,8 @@ from squidpy._constants._constants import Transform from squidpy._constants._pkg_constants import Key -from squidpy.gr import DelaunayBuilder, GridBuilder, KNNBuilder, RadiusBuilder, mask_graph, spatial_neighbors -from squidpy.gr._build import _build_connectivity +from squidpy.gr import mask_graph, spatial_neighbors +from squidpy.gr._build import DelaunayBuilder, GridBuilder, KNNBuilder, RadiusBuilder, _build_connectivity from squidpy.gr.neighbors import KNNBuilder as PublicKNNBuilder From f194b1081a9dcaabc8acf63f5be3c93c2444138a Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 2 Apr 2026 14:09:15 +0200 Subject: [PATCH 08/54] better behaviour --- src/squidpy/gr/_build.py | 105 +++++++++----------------- tests/graph/test_spatial_neighbors.py | 53 ++++++------- 2 files changed, 57 insertions(+), 101 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index d4506b8a8..140e06b26 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -313,59 +313,13 @@ def _filter_by_radius_interval( Adj.setdiag(a_diag) -def _normalize_builder_param(name: str, value: Any) -> Any: - if name == "coord_type": - return None if value is None else CoordType(value) - if name == "transform": - return Transform.NONE if value is None else Transform(value) - if name == "radius" and isinstance(value, tuple): - return tuple(sorted(value)[:2]) - - return value - - -def _validate_builder_compatibility( - builder: GraphBuilder, - *, - coord_type: str | CoordType | None, - n_neighs: int, - radius: float | tuple[float, float] | None, - delaunay: bool, - n_rings: int, - percentile: float | None, - transform: str | Transform | None, - set_diag: bool, -) -> None: - defaults = { - "coord_type": None, - "n_neighs": 6, - "radius": None, - "delaunay": False, - "n_rings": 1, - "percentile": None, - "transform": None, - "set_diag": False, - } - provided = { - "coord_type": coord_type, - "n_neighs": n_neighs, - "radius": radius, - "delaunay": delaunay, - "n_rings": n_rings, - "percentile": percentile, - "transform": transform, - "set_diag": set_diag, - } - - for key, value in provided.items(): - if value == defaults[key]: - continue - - if _normalize_builder_param(key, value) != _normalize_builder_param(key, builder.legacy_params[key]): - raise ValueError( - f"Parameter `{key}` conflicts with `{type(builder).__name__}`. " - "Leave graph-construction arguments at their defaults or make them match the builder." - ) +def _validate_no_legacy_params(**kwargs: Any) -> None: + conflicts = [k for k, v in kwargs.items() if v is not None] + if conflicts: + raise ValueError( + "When `builder` is provided, graph-construction arguments must not be set. " + f"Got non-default values for: {', '.join(conflicts)}." + ) def _resolve_graph_builder( @@ -425,13 +379,13 @@ def spatial_neighbors( table_key: str | None = None, library_key: str | None = None, coord_type: str | CoordType | None = None, - n_neighs: int = 6, + n_neighs: int | None = None, radius: float | tuple[float, float] | None = None, - delaunay: bool = False, - n_rings: int = 1, + delaunay: bool | None = None, + n_rings: int | None = None, percentile: float | None = None, transform: str | Transform | None = None, - set_diag: bool = False, + set_diag: bool | None = None, builder: GraphBuilder | None = None, key_added: str = "spatial", copy: bool = False, @@ -452,11 +406,10 @@ def spatial_neighbors( table_key Key in :attr:`spatialdata.SpatialData.tables` where the spatialdata table is stored. Must not be `None` if `adata` is a :class:`spatialdata.SpatialData`. - mask_polygon - The Polygon or MultiPolygon element. %(library_key)s coord_type - Type of coordinate system. Valid options are: + Type of coordinate system. Must not be set when ``builder`` is given. + Valid options are: - `{c.GRID.s!r}` - grid coordinates. - `{c.GENERIC.s!r}` - generic coordinates. @@ -467,29 +420,37 @@ def spatial_neighbors( - `{c.GRID.s!r}` - number of neighboring tiles. - `{c.GENERIC.s!r}` - number of neighborhoods for non-grid data. Only used when ``delaunay = False``. + + Defaults to ``6`` when no ``builder`` is provided. Must not be set when ``builder`` is given. radius - Only available when ``coord_type = {c.GENERIC.s!r}``. Depending on the type: + Only available when ``coord_type = {c.GENERIC.s!r}``. Must not be set when ``builder`` is given. + Depending on the type: - :class:`float` - compute the graph based on neighborhood radius. - :class:`tuple` - prune the final graph to only contain edges in interval `[min(radius), max(radius)]`. delaunay Whether to compute the graph from Delaunay triangulation. Only used when ``coord_type = {c.GENERIC.s!r}``. + Defaults to ``False`` when no ``builder`` is provided. Must not be set when ``builder`` is given. n_rings Number of rings of neighbors for grid data. Only used when ``coord_type = {c.GRID.s!r}``. + Defaults to ``1`` when no ``builder`` is provided. Must not be set when ``builder`` is given. percentile Percentile of the distances to use as threshold. Only used when ``coord_type = {c.GENERIC.s!r}``. + Must not be set when ``builder`` is given. transform - Type of adjacency matrix transform. Valid options are: + Type of adjacency matrix transform. Must not be set when ``builder`` is given. + Valid options are: - `{t.SPECTRAL.s!r}` - spectral transformation of the adjacency matrix. - `{t.COSINE.s!r}` - cosine transformation of the adjacency matrix. - `{t.NONE.v}` - no transformation of the adjacency matrix. set_diag Whether to set the diagonal of the spatial connectivities to `1.0`. + Defaults to ``False`` when no ``builder`` is provided. Must not be set when ``builder`` is given. builder - Advanced graph construction strategy. When provided, graph-construction arguments - (``coord_type``, ``n_neighs``, ``radius``, ``delaunay``, ``n_rings``, ``percentile``, - ``transform``, ``set_diag``) must either be left at their defaults or match the builder. + Advanced graph construction strategy. When provided, all other graph-construction + arguments (``coord_type``, ``n_neighs``, ``radius``, ``delaunay``, ``n_rings``, + ``percentile``, ``transform``, ``set_diag``) must be left as ``None``. key_added Key which controls where the results are saved if ``copy = False``. %(copy)s @@ -524,8 +485,8 @@ def spatial_neighbors( - ``percentile`` only affects generic graphs. - ``transform`` and ``set_diag`` apply to all modes. - If ``builder`` is provided, it determines the mode directly. - The legacy graph-construction arguments must then stay at - their defaults or match the builder configuration. + All other graph-construction arguments must be left as + ``None``. - By default, observations are not treated as their own neighbors. The distance matrix always has a zero diagonal. The connectivity matrix only gets a nonzero diagonal when @@ -622,10 +583,7 @@ def spatial_neighbors( _assert_spatial_basis(adata, spatial_key) if builder is not None: - if not isinstance(builder, GraphBuilder): - raise TypeError(f"Expected `builder` to be a `GraphBuilder`, found `{type(builder)}`.") - _validate_builder_compatibility( - builder, + _validate_no_legacy_params( coord_type=coord_type, n_neighs=n_neighs, radius=radius, @@ -636,6 +594,11 @@ def spatial_neighbors( set_diag=set_diag, ) else: + n_neighs = n_neighs if n_neighs is not None else 6 + delaunay = delaunay if delaunay is not None else False + n_rings = n_rings if n_rings is not None else 1 + set_diag = set_diag if set_diag is not None else False + assert_positive(n_rings, name="n_rings") assert_positive(n_neighs, name="n_neighs") diff --git a/tests/graph/test_spatial_neighbors.py b/tests/graph/test_spatial_neighbors.py index 851a81ced..6908a36c2 100644 --- a/tests/graph/test_spatial_neighbors.py +++ b/tests/graph/test_spatial_neighbors.py @@ -228,29 +228,26 @@ def test_grid_builder_matches_legacy(self, adata_squaregrid: AnnData, legacy_kwa np.testing.assert_allclose(legacy.distances.toarray(), result.distances.toarray()) def test_builder_argument_conflict(self, non_visium_adata: AnnData): - with pytest.raises(ValueError, match="conflicts"): + with pytest.raises(ValueError, match="must not be set"): spatial_neighbors(non_visium_adata, builder=RadiusBuilder(radius=5.0), delaunay=True) - def test_builder_matching_non_default_legacy_args(self, non_visium_adata: AnnData): + def test_builder_rejects_any_legacy_args(self, non_visium_adata: AnnData): builder = RadiusBuilder(radius=5.0, n_neighs=3, percentile=95.0, transform="cosine", set_diag=True) - baseline = spatial_neighbors(non_visium_adata, builder=builder, copy=True) - matched = spatial_neighbors( - non_visium_adata, - builder=builder, - coord_type="generic", - n_neighs=3, - radius=5.0, - percentile=95.0, - transform=Transform.COSINE, - set_diag=True, - copy=True, - ) - - np.testing.assert_allclose(baseline.connectivities.toarray(), matched.connectivities.toarray()) - np.testing.assert_allclose(baseline.distances.toarray(), matched.distances.toarray()) + with pytest.raises(ValueError, match="must not be set"): + spatial_neighbors( + non_visium_adata, + builder=builder, + coord_type="generic", + n_neighs=3, + radius=5.0, + percentile=95.0, + transform="cosine", + set_diag=True, + copy=True, + ) - def test_builder_compatibility_normalizes_none_transform(self, non_visium_adata: AnnData): + def test_builder_allows_none_legacy_args(self, non_visium_adata: AnnData): builder = KNNBuilder(n_neighs=3, transform=Transform.NONE) baseline = spatial_neighbors(non_visium_adata, builder=builder, copy=True) @@ -259,20 +256,16 @@ def test_builder_compatibility_normalizes_none_transform(self, non_visium_adata: np.testing.assert_array_equal(baseline.connectivities.toarray(), matched.connectivities.toarray()) np.testing.assert_allclose(baseline.distances.toarray(), matched.distances.toarray()) - def test_builder_compatibility_normalizes_tuple_radius_order(self, non_visium_adata: AnnData): + def test_builder_rejects_radius(self, non_visium_adata: AnnData): builder = RadiusBuilder(radius=(4.0, 2.0)) - baseline = spatial_neighbors(non_visium_adata, builder=builder, copy=True) - matched = spatial_neighbors( - non_visium_adata, - builder=builder, - coord_type="generic", - radius=(2.0, 4.0), - copy=True, - ) - - np.testing.assert_array_equal(baseline.connectivities.toarray(), matched.connectivities.toarray()) - np.testing.assert_allclose(baseline.distances.toarray(), matched.distances.toarray()) + with pytest.raises(ValueError, match="must not be set"): + spatial_neighbors( + non_visium_adata, + builder=builder, + radius=(2.0, 4.0), + copy=True, + ) def test_grid_mode_ignores_radius(self, adata_squaregrid: AnnData): default = spatial_neighbors(adata_squaregrid, coord_type="grid", n_neighs=4, n_rings=2, copy=True) From ae1eba1358d4dc866fb447fac81eeb149c5b44a5 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 2 Apr 2026 14:22:20 +0200 Subject: [PATCH 09/54] move classes --- src/squidpy/gr/_build.py | 445 +------------------------ src/squidpy/gr/neighbors.py | 456 +++++++++++++++++++++++++- tests/graph/test_spatial_neighbors.py | 12 +- 3 files changed, 472 insertions(+), 441 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 140e06b26..1c54fda49 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -2,11 +2,7 @@ from __future__ import annotations -import warnings -from abc import ABC, abstractmethod -from collections.abc import Iterable from functools import partial -from itertools import chain from typing import Any, NamedTuple, cast import geopandas as gpd @@ -14,20 +10,13 @@ import pandas as pd from anndata import AnnData from anndata.utils import make_index_unique -from fast_array_utils import stats as fau_stats -from numba import njit, prange +from numba import njit from scipy.sparse import ( - SparseEfficiencyWarning, block_diag, - csr_array, csr_matrix, - isspmatrix_csr, spmatrix, ) -from scipy.spatial import Delaunay from shapely import LineString, MultiPolygon, Polygon -from sklearn.metrics.pairwise import cosine_similarity, euclidean_distances -from sklearn.neighbors import NearestNeighbors from spatialdata import SpatialData from spatialdata._core.centroids import get_centroids from spatialdata._core.query.relational_query import get_element_instances, match_element_to_table @@ -49,6 +38,13 @@ _assert_spatial_basis, _save_data, ) +from squidpy.gr.neighbors import ( + DelaunayBuilder, + GraphBuilder, + GridBuilder, + KNNBuilder, + RadiusBuilder, +) __all__ = [ "SpatialNeighborsResult", @@ -63,256 +59,6 @@ class SpatialNeighborsResult(NamedTuple): distances: csr_matrix -class GraphBuilder(ABC): - """Base class for spatial graph construction strategies.""" - - def __init__( - self, - transform: str | Transform | None = None, - set_diag: bool = False, - percentile: float | None = None, - ) -> None: - self.transform = Transform.NONE if transform is None else Transform(transform) - self.set_diag = set_diag - self.percentile = percentile - - @property - @abstractmethod - def coord_type(self) -> CoordType: - """Coordinate system supported by this builder.""" - - @property - def legacy_params(self) -> dict[str, Any]: - """Return parameters expressed in the legacy spatial_neighbors API.""" - return { - "coord_type": self.coord_type, - "n_neighs": 6, - "radius": None, - "delaunay": False, - "n_rings": 1, - "percentile": self.percentile, - "transform": self.transform, - "set_diag": self.set_diag, - } - - @property - def metadata(self) -> dict[str, Any]: - """Return metadata stored in adata.uns after graph construction.""" - params = self.legacy_params - return { - "n_neighbors": params["n_neighs"], - "coord_type": self.coord_type.v, - "radius": params["radius"], - "transform": self.transform.v, - } - - def build(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: - with warnings.catch_warnings(): - warnings.simplefilter("ignore", SparseEfficiencyWarning) - Adj, Dst = self._build_graph(coords) - - self._apply_filters(Adj, Dst) - self._apply_percentile(Adj, Dst) - Adj.eliminate_zeros() - Dst.eliminate_zeros() - - return self._apply_transform(Adj), Dst - - @abstractmethod - def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: - """Construct raw adjacency and distance matrices.""" - - def _apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: - """Apply builder-specific post-processing filters.""" - return None - - def _apply_percentile(self, Adj: csr_matrix, Dst: csr_matrix) -> None: - if self.percentile is not None and self.coord_type == CoordType.GENERIC: - threshold = np.percentile(Dst.data, self.percentile) - Adj[Dst > threshold] = 0.0 - Dst[Dst > threshold] = 0.0 - - def _apply_transform(self, Adj: csr_matrix) -> csr_matrix: - if self.transform == Transform.SPECTRAL: - return cast(csr_matrix, _transform_a_spectral(Adj)) - if self.transform == Transform.COSINE: - return cast(csr_matrix, _transform_a_cosine(Adj)) - if self.transform == Transform.NONE: - return Adj - - raise NotImplementedError(f"Transform `{self.transform}` is not yet implemented.") - - -class KNNBuilder(GraphBuilder): - """Build a generic k-nearest-neighbor spatial graph.""" - - def __init__( - self, - n_neighs: int = 6, - transform: str | Transform | None = None, - set_diag: bool = False, - percentile: float | None = None, - ) -> None: - assert_positive(n_neighs, name="n_neighs") - super().__init__(transform=transform, set_diag=set_diag, percentile=percentile) - self.n_neighs = n_neighs - - @property - def coord_type(self) -> CoordType: - return CoordType.GENERIC - - @property - def legacy_params(self) -> dict[str, Any]: - return super().legacy_params | {"n_neighs": self.n_neighs} - - def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: - return cast( - tuple[csr_matrix, csr_matrix], - _build_connectivity( - coords, - n_neighs=self.n_neighs, - return_distance=True, - set_diag=self.set_diag, - ), - ) - - -class _RadiusFilterBuilder(GraphBuilder): - """Intermediate base for builders that support radius-interval pruning.""" - - radius: float | tuple[float, float] | None - n_neighs: int - - @property - def coord_type(self) -> CoordType: - return CoordType.GENERIC - - def _apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: - if isinstance(self.radius, Iterable): - _filter_by_radius_interval(Adj, Dst, self.radius) - - -class RadiusBuilder(_RadiusFilterBuilder): - """Build a generic radius-based spatial graph.""" - - def __init__( - self, - radius: float | tuple[float, float], - n_neighs: int = 6, - transform: str | Transform | None = None, - set_diag: bool = False, - percentile: float | None = None, - ) -> None: - assert_positive(n_neighs, name="n_neighs") - super().__init__(transform=transform, set_diag=set_diag, percentile=percentile) - self.radius = radius - self.n_neighs = n_neighs - - @property - def legacy_params(self) -> dict[str, Any]: - return super().legacy_params | {"n_neighs": self.n_neighs, "radius": self.radius} - - def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: - return cast( - tuple[csr_matrix, csr_matrix], - _build_connectivity( - coords, - n_neighs=self.n_neighs, - radius=self.radius, - return_distance=True, - set_diag=self.set_diag, - ), - ) - - -class DelaunayBuilder(_RadiusFilterBuilder): - """Build a generic spatial graph from a Delaunay triangulation.""" - - def __init__( - self, - radius: float | tuple[float, float] | None = None, - n_neighs: int = 6, - transform: str | Transform | None = None, - set_diag: bool = False, - percentile: float | None = None, - ) -> None: - assert_positive(n_neighs, name="n_neighs") - super().__init__(transform=transform, set_diag=set_diag, percentile=percentile) - self.radius = radius - self.n_neighs = n_neighs - - @property - def legacy_params(self) -> dict[str, Any]: - return super().legacy_params | {"n_neighs": self.n_neighs, "radius": self.radius, "delaunay": True} - - def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: - return cast( - tuple[csr_matrix, csr_matrix], - _build_connectivity( - coords, - n_neighs=self.n_neighs, - radius=self.radius, - delaunay=True, - return_distance=True, - set_diag=self.set_diag, - ), - ) - - -class GridBuilder(GraphBuilder): - """Build a grid-based spatial graph.""" - - def __init__( - self, - n_neighs: int = 6, - n_rings: int = 1, - delaunay: bool = False, - transform: str | Transform | None = None, - set_diag: bool = False, - ) -> None: - assert_positive(n_neighs, name="n_neighs") - assert_positive(n_rings, name="n_rings") - super().__init__(transform=transform, set_diag=set_diag, percentile=None) - self.n_neighs = n_neighs - self.n_rings = n_rings - self.delaunay = delaunay - - @property - def coord_type(self) -> CoordType: - return CoordType.GRID - - @property - def legacy_params(self) -> dict[str, Any]: - return super().legacy_params | { - "n_neighs": self.n_neighs, - "delaunay": self.delaunay, - "n_rings": self.n_rings, - } - - def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: - return _build_grid( - coords, - n_neighs=self.n_neighs, - n_rings=self.n_rings, - delaunay=self.delaunay, - set_diag=self.set_diag, - ) - - -def _filter_by_radius_interval( - Adj: csr_matrix, - Dst: csr_matrix, - radius: Iterable[float], -) -> None: - minn, maxx = sorted(radius)[:2] - mask = (Dst.data < minn) | (Dst.data > maxx) - a_diag = Adj.diagonal() - - Dst.data[mask] = 0.0 - Adj.data[mask] = 0.0 - Adj.setdiag(a_diag) - - def _validate_no_legacy_params(**kwargs: Any) -> None: conflicts = [k for k, v in kwargs.items() if v is not None] if conflicts: @@ -682,181 +428,6 @@ def _spatial_neighbor( return builder.build(coords) -def _build_grid( - coords: NDArrayA, - n_neighs: int, - n_rings: int, - delaunay: bool = False, - set_diag: bool = False, -) -> tuple[csr_matrix, csr_matrix]: - if n_rings > 1: - Adj: csr_matrix = _build_connectivity( - coords, - n_neighs=n_neighs, - neigh_correct=True, - set_diag=True, - delaunay=delaunay, - return_distance=False, - ) - Res, Walk = Adj, Adj - for i in range(n_rings - 1): - Walk = Walk @ Adj - Walk[Res.nonzero()] = 0.0 - Walk.eliminate_zeros() - Walk.data[:] = i + 2.0 - Res = Res + Walk - Adj = Res - Adj.setdiag(float(set_diag)) - Adj.eliminate_zeros() - - Dst = Adj.copy() - Adj.data[:] = 1.0 - else: - Adj = _build_connectivity( - coords, - n_neighs=n_neighs, - neigh_correct=True, - delaunay=delaunay, - set_diag=set_diag, - ) - Dst = Adj.copy() - - Dst.setdiag(0.0) - - return Adj, Dst - - -def _build_connectivity( - coords: NDArrayA, - n_neighs: int, - radius: float | tuple[float, float] | None = None, - delaunay: bool = False, - neigh_correct: bool = False, - set_diag: bool = False, - return_distance: bool = False, -) -> csr_matrix | tuple[csr_matrix, csr_matrix]: - N = coords.shape[0] - if delaunay: - tri = Delaunay(coords) - indptr, indices = tri.vertex_neighbor_vertices - Adj = csr_matrix((np.ones_like(indices, dtype=np.float32), indices, indptr), shape=(N, N)) - - if return_distance: - # fmt: off - dists = np.array(list(chain(*( - euclidean_distances(coords[indices[indptr[i] : indptr[i + 1]], :], coords[np.newaxis, i, :]) - for i in range(N) - if len(indices[indptr[i] : indptr[i + 1]]) - )))).squeeze() - Dst = csr_matrix((dists, indices, indptr), shape=(N, N)) - # fmt: on - else: - r = 1 if radius is None else radius if isinstance(radius, int | float) else max(radius) - tree = NearestNeighbors(n_neighbors=n_neighs, radius=r, metric="euclidean") - tree.fit(coords) - - if radius is None: - dists, col_indices = tree.kneighbors() - dists, col_indices = dists.reshape(-1), col_indices.reshape(-1) - row_indices = np.repeat(np.arange(N), n_neighs) - if neigh_correct: - dist_cutoff = np.median(dists) * 1.3 # there's a small amount of sway - mask = dists < dist_cutoff - row_indices, col_indices, dists = ( - row_indices[mask], - col_indices[mask], - dists[mask], - ) - else: - dists, col_indices = tree.radius_neighbors() - row_indices = np.repeat(np.arange(N), [len(x) for x in col_indices]) - dists = np.concatenate(dists) - col_indices = np.concatenate(col_indices) - - Adj = csr_matrix( - (np.ones_like(row_indices, dtype=np.float32), (row_indices, col_indices)), - shape=(N, N), - ) - if return_distance: - Dst = csr_matrix((dists, (row_indices, col_indices)), shape=(N, N)) - - # radius-based filtering needs same indices/indptr: do not remove 0s - Adj.setdiag(1.0 if set_diag else Adj.diagonal()) - if return_distance: - Dst.setdiag(0.0) - return Adj, Dst - - return Adj - - -@njit -def _csr_bilateral_diag_scale_helper( - mat: csr_array | csr_matrix, - degrees: NDArrayA, -) -> NDArrayA: - """ - Return an array F aligned with CSR non-zeros such that - F[k] = d[i] * data[k] * d[j] for the k-th non-zero (i, j) in CSR order. - - Parameters - ---------- - - data : array of float - CSR `data` (non-zero values). - indices : array of int - CSR `indices` (column indices). - indptr : array of int - CSR `indptr` (row pointer). - degrees : array of float, shape (n,) - Diagonal scaling vector. - - Returns - ------- - array of float - Length equals len(data). Entry-wise factors d_i * d_j * data[k] - """ - - res = np.empty_like(mat.data, dtype=np.float32) - for i in prange(len(mat.indptr) - 1): - ixs = mat.indices[mat.indptr[i] : mat.indptr[i + 1]] - res[mat.indptr[i] : mat.indptr[i + 1]] = degrees[i] * degrees[ixs] * mat.data[mat.indptr[i] : mat.indptr[i + 1]] - - return res - - -def symmetric_normalize_csr(adj: spmatrix) -> csr_matrix: - """ - Return D^{-1/2} * A * D^{-1/2}, where D = diag(degrees(A)) and A = adj. - - - Parameters - ---------- - adj : scipy.sparse.csr_matrix - - Returns - ------- - scipy.sparse.csr_matrix - """ - degrees = np.squeeze(np.array(np.sqrt(1.0 / fau_stats.sum(adj, axis=0)))) - if adj.shape[0] != len(degrees): - raise ValueError("len(degrees) must equal number of rows of adj") - res_data = _csr_bilateral_diag_scale_helper(adj, degrees) - return csr_matrix((res_data, adj.indices, adj.indptr), shape=adj.shape) - - -def _transform_a_spectral(a: spmatrix) -> spmatrix: - if not isspmatrix_csr(a): - a = a.tocsr() - if not a.nnz: - return a - - return symmetric_normalize_csr(a) - - -def _transform_a_cosine(a: spmatrix) -> spmatrix: - return cosine_similarity(a, dense_output=False) - - @d.dedent def mask_graph( sdata: SpatialData, diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index 3e9d1d14c..79cf62195 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -1,7 +1,459 @@ -"""Public builder API for spatial neighbor graph construction.""" +"""Builder classes and helpers for spatial neighbor graph construction.""" from __future__ import annotations -from squidpy.gr._build import DelaunayBuilder, GraphBuilder, GridBuilder, KNNBuilder, RadiusBuilder +import warnings +from abc import ABC, abstractmethod +from collections.abc import Iterable +from itertools import chain +from typing import Any, cast + +import numpy as np +from fast_array_utils import stats as fau_stats +from numba import njit, prange +from scipy.sparse import ( + SparseEfficiencyWarning, + csr_array, + csr_matrix, + isspmatrix_csr, + spmatrix, +) +from scipy.spatial import Delaunay +from sklearn.metrics.pairwise import cosine_similarity, euclidean_distances +from sklearn.neighbors import NearestNeighbors + +from squidpy._constants._constants import CoordType, Transform +from squidpy._utils import NDArrayA +from squidpy._validators import assert_positive __all__ = ["GraphBuilder", "KNNBuilder", "RadiusBuilder", "DelaunayBuilder", "GridBuilder"] + + +class GraphBuilder(ABC): + """Base class for spatial graph construction strategies.""" + + def __init__( + self, + transform: str | Transform | None = None, + set_diag: bool = False, + percentile: float | None = None, + ) -> None: + self.transform = Transform.NONE if transform is None else Transform(transform) + self.set_diag = set_diag + self.percentile = percentile + + @property + @abstractmethod + def coord_type(self) -> CoordType: + """Coordinate system supported by this builder.""" + + @property + def legacy_params(self) -> dict[str, Any]: + """Return parameters expressed in the legacy spatial_neighbors API.""" + return { + "coord_type": self.coord_type, + "n_neighs": 6, + "radius": None, + "delaunay": False, + "n_rings": 1, + "percentile": self.percentile, + "transform": self.transform, + "set_diag": self.set_diag, + } + + @property + def metadata(self) -> dict[str, Any]: + """Return metadata stored in adata.uns after graph construction.""" + params = self.legacy_params + return { + "n_neighbors": params["n_neighs"], + "coord_type": self.coord_type.v, + "radius": params["radius"], + "transform": self.transform.v, + } + + def build(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", SparseEfficiencyWarning) + Adj, Dst = self._build_graph(coords) + + self._apply_filters(Adj, Dst) + self._apply_percentile(Adj, Dst) + Adj.eliminate_zeros() + Dst.eliminate_zeros() + + return self._apply_transform(Adj), Dst + + @abstractmethod + def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: + """Construct raw adjacency and distance matrices.""" + + def _apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: + """Apply builder-specific post-processing filters.""" + return None + + def _apply_percentile(self, Adj: csr_matrix, Dst: csr_matrix) -> None: + if self.percentile is not None and self.coord_type == CoordType.GENERIC: + threshold = np.percentile(Dst.data, self.percentile) + Adj[Dst > threshold] = 0.0 + Dst[Dst > threshold] = 0.0 + + def _apply_transform(self, Adj: csr_matrix) -> csr_matrix: + if self.transform == Transform.SPECTRAL: + return cast(csr_matrix, _transform_a_spectral(Adj)) + if self.transform == Transform.COSINE: + return cast(csr_matrix, _transform_a_cosine(Adj)) + if self.transform == Transform.NONE: + return Adj + + raise NotImplementedError(f"Transform `{self.transform}` is not yet implemented.") + + +class KNNBuilder(GraphBuilder): + """Build a generic k-nearest-neighbor spatial graph.""" + + def __init__( + self, + n_neighs: int = 6, + transform: str | Transform | None = None, + set_diag: bool = False, + percentile: float | None = None, + ) -> None: + assert_positive(n_neighs, name="n_neighs") + super().__init__(transform=transform, set_diag=set_diag, percentile=percentile) + self.n_neighs = n_neighs + + @property + def coord_type(self) -> CoordType: + return CoordType.GENERIC + + @property + def legacy_params(self) -> dict[str, Any]: + return super().legacy_params | {"n_neighs": self.n_neighs} + + def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: + return cast( + tuple[csr_matrix, csr_matrix], + _build_connectivity( + coords, + n_neighs=self.n_neighs, + return_distance=True, + set_diag=self.set_diag, + ), + ) + + +class _RadiusFilterBuilder(GraphBuilder): + """Intermediate base for builders that support radius-interval pruning.""" + + radius: float | tuple[float, float] | None + n_neighs: int + + @property + def coord_type(self) -> CoordType: + return CoordType.GENERIC + + def _apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: + if isinstance(self.radius, Iterable): + _filter_by_radius_interval(Adj, Dst, self.radius) + + +class RadiusBuilder(_RadiusFilterBuilder): + """Build a generic radius-based spatial graph.""" + + def __init__( + self, + radius: float | tuple[float, float], + n_neighs: int = 6, + transform: str | Transform | None = None, + set_diag: bool = False, + percentile: float | None = None, + ) -> None: + assert_positive(n_neighs, name="n_neighs") + super().__init__(transform=transform, set_diag=set_diag, percentile=percentile) + self.radius = radius + self.n_neighs = n_neighs + + @property + def legacy_params(self) -> dict[str, Any]: + return super().legacy_params | {"n_neighs": self.n_neighs, "radius": self.radius} + + def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: + return cast( + tuple[csr_matrix, csr_matrix], + _build_connectivity( + coords, + n_neighs=self.n_neighs, + radius=self.radius, + return_distance=True, + set_diag=self.set_diag, + ), + ) + + +class DelaunayBuilder(_RadiusFilterBuilder): + """Build a generic spatial graph from a Delaunay triangulation.""" + + def __init__( + self, + radius: float | tuple[float, float] | None = None, + n_neighs: int = 6, + transform: str | Transform | None = None, + set_diag: bool = False, + percentile: float | None = None, + ) -> None: + assert_positive(n_neighs, name="n_neighs") + super().__init__(transform=transform, set_diag=set_diag, percentile=percentile) + self.radius = radius + self.n_neighs = n_neighs + + @property + def legacy_params(self) -> dict[str, Any]: + return super().legacy_params | {"n_neighs": self.n_neighs, "radius": self.radius, "delaunay": True} + + def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: + return cast( + tuple[csr_matrix, csr_matrix], + _build_connectivity( + coords, + n_neighs=self.n_neighs, + radius=self.radius, + delaunay=True, + return_distance=True, + set_diag=self.set_diag, + ), + ) + + +class GridBuilder(GraphBuilder): + """Build a grid-based spatial graph.""" + + def __init__( + self, + n_neighs: int = 6, + n_rings: int = 1, + delaunay: bool = False, + transform: str | Transform | None = None, + set_diag: bool = False, + ) -> None: + assert_positive(n_neighs, name="n_neighs") + assert_positive(n_rings, name="n_rings") + super().__init__(transform=transform, set_diag=set_diag, percentile=None) + self.n_neighs = n_neighs + self.n_rings = n_rings + self.delaunay = delaunay + + @property + def coord_type(self) -> CoordType: + return CoordType.GRID + + @property + def legacy_params(self) -> dict[str, Any]: + return super().legacy_params | { + "n_neighs": self.n_neighs, + "delaunay": self.delaunay, + "n_rings": self.n_rings, + } + + def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: + return _build_grid( + coords, + n_neighs=self.n_neighs, + n_rings=self.n_rings, + delaunay=self.delaunay, + set_diag=self.set_diag, + ) + + +# --------------------------------------------------------------------------- +# Private helpers used by the builder classes +# --------------------------------------------------------------------------- + + +def _filter_by_radius_interval( + Adj: csr_matrix, + Dst: csr_matrix, + radius: Iterable[float], +) -> None: + minn, maxx = sorted(radius)[:2] + mask = (Dst.data < minn) | (Dst.data > maxx) + a_diag = Adj.diagonal() + + Dst.data[mask] = 0.0 + Adj.data[mask] = 0.0 + Adj.setdiag(a_diag) + + +def _build_grid( + coords: NDArrayA, + n_neighs: int, + n_rings: int, + delaunay: bool = False, + set_diag: bool = False, +) -> tuple[csr_matrix, csr_matrix]: + if n_rings > 1: + Adj: csr_matrix = _build_connectivity( + coords, + n_neighs=n_neighs, + neigh_correct=True, + set_diag=True, + delaunay=delaunay, + return_distance=False, + ) + Res, Walk = Adj, Adj + for i in range(n_rings - 1): + Walk = Walk @ Adj + Walk[Res.nonzero()] = 0.0 + Walk.eliminate_zeros() + Walk.data[:] = i + 2.0 + Res = Res + Walk + Adj = Res + Adj.setdiag(float(set_diag)) + Adj.eliminate_zeros() + + Dst = Adj.copy() + Adj.data[:] = 1.0 + else: + Adj = _build_connectivity( + coords, + n_neighs=n_neighs, + neigh_correct=True, + delaunay=delaunay, + set_diag=set_diag, + ) + Dst = Adj.copy() + + Dst.setdiag(0.0) + + return Adj, Dst + + +def _build_connectivity( + coords: NDArrayA, + n_neighs: int, + radius: float | tuple[float, float] | None = None, + delaunay: bool = False, + neigh_correct: bool = False, + set_diag: bool = False, + return_distance: bool = False, +) -> csr_matrix | tuple[csr_matrix, csr_matrix]: + N = coords.shape[0] + if delaunay: + tri = Delaunay(coords) + indptr, indices = tri.vertex_neighbor_vertices + Adj = csr_matrix((np.ones_like(indices, dtype=np.float32), indices, indptr), shape=(N, N)) + + if return_distance: + # fmt: off + dists = np.array(list(chain(*( + euclidean_distances(coords[indices[indptr[i] : indptr[i + 1]], :], coords[np.newaxis, i, :]) + for i in range(N) + if len(indices[indptr[i] : indptr[i + 1]]) + )))).squeeze() + Dst = csr_matrix((dists, indices, indptr), shape=(N, N)) + # fmt: on + else: + r = 1 if radius is None else radius if isinstance(radius, int | float) else max(radius) + tree = NearestNeighbors(n_neighbors=n_neighs, radius=r, metric="euclidean") + tree.fit(coords) + + if radius is None: + dists, col_indices = tree.kneighbors() + dists, col_indices = dists.reshape(-1), col_indices.reshape(-1) + row_indices = np.repeat(np.arange(N), n_neighs) + if neigh_correct: + dist_cutoff = np.median(dists) * 1.3 # there's a small amount of sway + mask = dists < dist_cutoff + row_indices, col_indices, dists = ( + row_indices[mask], + col_indices[mask], + dists[mask], + ) + else: + dists, col_indices = tree.radius_neighbors() + row_indices = np.repeat(np.arange(N), [len(x) for x in col_indices]) + dists = np.concatenate(dists) + col_indices = np.concatenate(col_indices) + + Adj = csr_matrix( + (np.ones_like(row_indices, dtype=np.float32), (row_indices, col_indices)), + shape=(N, N), + ) + if return_distance: + Dst = csr_matrix((dists, (row_indices, col_indices)), shape=(N, N)) + + # radius-based filtering needs same indices/indptr: do not remove 0s + Adj.setdiag(1.0 if set_diag else Adj.diagonal()) + if return_distance: + Dst.setdiag(0.0) + return Adj, Dst + + return Adj + + +@njit +def _csr_bilateral_diag_scale_helper( + mat: csr_array | csr_matrix, + degrees: NDArrayA, +) -> NDArrayA: + """ + Return an array F aligned with CSR non-zeros such that + F[k] = d[i] * data[k] * d[j] for the k-th non-zero (i, j) in CSR order. + + Parameters + ---------- + + data : array of float + CSR `data` (non-zero values). + indices : array of int + CSR `indices` (column indices). + indptr : array of int + CSR `indptr` (row pointer). + degrees : array of float, shape (n,) + Diagonal scaling vector. + + Returns + ------- + array of float + Length equals len(data). Entry-wise factors d_i * d_j * data[k] + """ + + res = np.empty_like(mat.data, dtype=np.float32) + for i in prange(len(mat.indptr) - 1): + ixs = mat.indices[mat.indptr[i] : mat.indptr[i + 1]] + res[mat.indptr[i] : mat.indptr[i + 1]] = degrees[i] * degrees[ixs] * mat.data[mat.indptr[i] : mat.indptr[i + 1]] + + return res + + +def symmetric_normalize_csr(adj: spmatrix) -> csr_matrix: + """ + Return D^{-1/2} * A * D^{-1/2}, where D = diag(degrees(A)) and A = adj. + + + Parameters + ---------- + adj : scipy.sparse.csr_matrix + + Returns + ------- + scipy.sparse.csr_matrix + """ + degrees = np.squeeze(np.array(np.sqrt(1.0 / fau_stats.sum(adj, axis=0)))) + if adj.shape[0] != len(degrees): + raise ValueError("len(degrees) must equal number of rows of adj") + res_data = _csr_bilateral_diag_scale_helper(adj, degrees) + return csr_matrix((res_data, adj.indices, adj.indptr), shape=adj.shape) + + +def _transform_a_spectral(a: spmatrix) -> spmatrix: + if not isspmatrix_csr(a): + a = a.tocsr() + if not a.nnz: + return a + + return symmetric_normalize_csr(a) + + +def _transform_a_cosine(a: spmatrix) -> spmatrix: + return cosine_similarity(a, dense_output=False) diff --git a/tests/graph/test_spatial_neighbors.py b/tests/graph/test_spatial_neighbors.py index 6908a36c2..8cf32c2c5 100644 --- a/tests/graph/test_spatial_neighbors.py +++ b/tests/graph/test_spatial_neighbors.py @@ -13,8 +13,16 @@ from squidpy._constants._constants import Transform from squidpy._constants._pkg_constants import Key from squidpy.gr import mask_graph, spatial_neighbors -from squidpy.gr._build import DelaunayBuilder, GridBuilder, KNNBuilder, RadiusBuilder, _build_connectivity -from squidpy.gr.neighbors import KNNBuilder as PublicKNNBuilder +from squidpy.gr.neighbors import ( + DelaunayBuilder, + GridBuilder, + KNNBuilder, + RadiusBuilder, + _build_connectivity, +) +from squidpy.gr.neighbors import ( + KNNBuilder as PublicKNNBuilder, +) class TestSpatialNeighbors: From 039b8527d857ce3816aba661db7e2dd1d735d4a6 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 2 Apr 2026 14:29:01 +0200 Subject: [PATCH 10/54] cleanup --- src/squidpy/gr/__init__.py | 2 ++ src/squidpy/gr/_build.py | 7 +++++- src/squidpy/gr/neighbors.py | 47 +------------------------------------ 3 files changed, 9 insertions(+), 47 deletions(-) diff --git a/src/squidpy/gr/__init__.py b/src/squidpy/gr/__init__.py index d4f39958e..6053b2743 100644 --- a/src/squidpy/gr/__init__.py +++ b/src/squidpy/gr/__init__.py @@ -7,6 +7,7 @@ mask_graph, spatial_neighbors, ) +from squidpy.gr import neighbors from squidpy.gr._ligrec import ligrec from squidpy.gr._nhood import ( NhoodEnrichmentResult, @@ -22,6 +23,7 @@ __all__ = [ "SpatialNeighborsResult", "NhoodEnrichmentResult", + "neighbors", "mask_graph", "spatial_neighbors", "ligrec", diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 1c54fda49..5bfbb1ceb 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -405,7 +405,12 @@ def spatial_neighbors( neighbors_dict = { "connectivities_key": conns_key, "distances_key": dists_key, - "params": builder.metadata, + "params": { + "n_neighbors": getattr(builder, "n_neighs", 6), + "coord_type": builder.coord_type.v, + "radius": getattr(builder, "radius", None), + "transform": builder.transform.v, + }, } if copy: diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index 79cf62195..00a630168 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -6,7 +6,7 @@ from abc import ABC, abstractmethod from collections.abc import Iterable from itertools import chain -from typing import Any, cast +from typing import cast import numpy as np from fast_array_utils import stats as fau_stats @@ -47,31 +47,6 @@ def __init__( def coord_type(self) -> CoordType: """Coordinate system supported by this builder.""" - @property - def legacy_params(self) -> dict[str, Any]: - """Return parameters expressed in the legacy spatial_neighbors API.""" - return { - "coord_type": self.coord_type, - "n_neighs": 6, - "radius": None, - "delaunay": False, - "n_rings": 1, - "percentile": self.percentile, - "transform": self.transform, - "set_diag": self.set_diag, - } - - @property - def metadata(self) -> dict[str, Any]: - """Return metadata stored in adata.uns after graph construction.""" - params = self.legacy_params - return { - "n_neighbors": params["n_neighs"], - "coord_type": self.coord_type.v, - "radius": params["radius"], - "transform": self.transform.v, - } - def build(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: with warnings.catch_warnings(): warnings.simplefilter("ignore", SparseEfficiencyWarning) @@ -127,10 +102,6 @@ def __init__( def coord_type(self) -> CoordType: return CoordType.GENERIC - @property - def legacy_params(self) -> dict[str, Any]: - return super().legacy_params | {"n_neighs": self.n_neighs} - def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: return cast( tuple[csr_matrix, csr_matrix], @@ -174,10 +145,6 @@ def __init__( self.radius = radius self.n_neighs = n_neighs - @property - def legacy_params(self) -> dict[str, Any]: - return super().legacy_params | {"n_neighs": self.n_neighs, "radius": self.radius} - def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: return cast( tuple[csr_matrix, csr_matrix], @@ -207,10 +174,6 @@ def __init__( self.radius = radius self.n_neighs = n_neighs - @property - def legacy_params(self) -> dict[str, Any]: - return super().legacy_params | {"n_neighs": self.n_neighs, "radius": self.radius, "delaunay": True} - def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: return cast( tuple[csr_matrix, csr_matrix], @@ -247,14 +210,6 @@ def __init__( def coord_type(self) -> CoordType: return CoordType.GRID - @property - def legacy_params(self) -> dict[str, Any]: - return super().legacy_params | { - "n_neighs": self.n_neighs, - "delaunay": self.delaunay, - "n_rings": self.n_rings, - } - def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: return _build_grid( coords, From b5dcf9f59a7bf4086a021b2d36174dd0ce86d5da Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:29:39 +0000 Subject: [PATCH 11/54] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/squidpy/gr/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/squidpy/gr/__init__.py b/src/squidpy/gr/__init__.py index 6053b2743..a64930622 100644 --- a/src/squidpy/gr/__init__.py +++ b/src/squidpy/gr/__init__.py @@ -2,12 +2,12 @@ from __future__ import annotations +from squidpy.gr import neighbors from squidpy.gr._build import ( SpatialNeighborsResult, mask_graph, spatial_neighbors, ) -from squidpy.gr import neighbors from squidpy.gr._ligrec import ligrec from squidpy.gr._nhood import ( NhoodEnrichmentResult, From 4c11adfd096cd4db9eb50bc7d8d5f7af67f13f03 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 2 Apr 2026 14:36:56 +0200 Subject: [PATCH 12/54] add docs --- docs/api.md | 17 +++++++++++++++++ src/squidpy/gr/neighbors.py | 19 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/docs/api.md b/docs/api.md index fc9922a31..59b08f632 100644 --- a/docs/api.md +++ b/docs/api.md @@ -109,3 +109,20 @@ import squidpy as sq datasets.visium_hne_image_crop datasets.visium_fluo_image_crop ``` +## Types + + + +## Extensibility +```{eval-rst} +.. module:: squidpy.gr.neighbors +.. currentmodule:: squidpy +.. autosummary:: + :toctree: api + + gr.neighbors.GraphBuilder + gr.neighbors.KNNBuilder + gr.neighbors.RadiusBuilder + gr.neighbors.DelaunayBuilder + gr.neighbors.GridBuilder +``` diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index 00a630168..0c71aedc7 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -1,4 +1,21 @@ -"""Builder classes and helpers for spatial neighbor graph construction.""" +"""Graph construction strategies for :func:`squidpy.gr.spatial_neighbors`. + +This module provides the builder classes that control how spatial neighbor +graphs are constructed. :func:`~squidpy.gr.spatial_neighbors` accepts a +``builder`` argument; when supplied, the builder fully determines the +graph-construction algorithm and its parameters. + +Built-in builders cover the common cases: + +- :class:`KNNBuilder` -- k-nearest-neighbor graph. +- :class:`RadiusBuilder` -- radius-based graph. +- :class:`DelaunayBuilder` -- Delaunay triangulation graph. +- :class:`GridBuilder` -- grid (Visium-style) graph. + +To implement a custom strategy, subclass :class:`GraphBuilder` and override +:meth:`~GraphBuilder._build_graph`. The returned object can then be passed +to ``spatial_neighbors(adata, builder=my_builder)``. +""" from __future__ import annotations From ea1ce936aafb0185db05e2cd56c3484f28a55a82 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 2 Apr 2026 14:39:10 +0200 Subject: [PATCH 13/54] remove leftover --- docs/api.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/api.md b/docs/api.md index 59b08f632..37610f75a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -109,8 +109,6 @@ import squidpy as sq datasets.visium_hne_image_crop datasets.visium_fluo_image_crop ``` -## Types - ## Extensibility From ebb7fd52cb648873c02d373a9bb10cf7f6a180ae Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 2 Apr 2026 14:41:41 +0200 Subject: [PATCH 14/54] resolution in reolve func --- src/squidpy/gr/_build.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 5bfbb1ceb..ab611b496 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -70,15 +70,27 @@ def _validate_no_legacy_params(**kwargs: Any) -> None: def _resolve_graph_builder( *, - coord_type: CoordType, + coord_type: str | CoordType | None, n_neighs: int, radius: float | tuple[float, float] | None, delaunay: bool, n_rings: int, percentile: float | None, - transform: Transform, + transform: str | Transform | None, set_diag: bool, + has_spatial_uns: bool = False, ) -> GraphBuilder: + transform = Transform.NONE if transform is None else Transform(transform) + if coord_type is None: + if radius is not None: + logg.warning( + f"Graph creation with `radius` is only available when `coord_type = {CoordType.GENERIC!r}` specified. " + f"Ignoring parameter `radius = {radius}`." + ) + coord_type = CoordType.GRID if has_spatial_uns else CoordType.GENERIC + else: + coord_type = CoordType(coord_type) + if coord_type == CoordType.GRID: if percentile is not None: raise ValueError( @@ -348,17 +360,6 @@ def spatial_neighbors( assert_positive(n_rings, name="n_rings") assert_positive(n_neighs, name="n_neighs") - transform = Transform.NONE if transform is None else Transform(transform) - if coord_type is None: - if radius is not None: - logg.warning( - f"Graph creation with `radius` is only available when `coord_type = {CoordType.GENERIC!r}` specified. " - f"Ignoring parameter `radius = {radius}`." - ) - coord_type = CoordType.GRID if Key.uns.spatial in adata.uns else CoordType.GENERIC - else: - coord_type = CoordType(coord_type) - builder = _resolve_graph_builder( coord_type=coord_type, n_neighs=n_neighs, @@ -368,6 +369,7 @@ def spatial_neighbors( percentile=percentile, transform=transform, set_diag=set_diag, + has_spatial_uns=Key.uns.spatial in adata.uns, ) if library_key is not None: From 69a939492b0f18409d1793600560b315bb024ea6 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 2 Apr 2026 14:45:03 +0200 Subject: [PATCH 15/54] reduce dup code --- src/squidpy/gr/_build.py | 38 ++++++++++---------------------------- 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index ab611b496..f61da6df5 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -91,41 +91,23 @@ def _resolve_graph_builder( else: coord_type = CoordType(coord_type) + common: dict[str, Any] = { + "n_neighs": n_neighs, + "transform": transform, + "set_diag": set_diag, + } + if coord_type == CoordType.GRID: if percentile is not None: raise ValueError( "`percentile` is not supported for grid coordinates. It only applies to generic (non-grid) graphs." ) - return GridBuilder( - n_neighs=n_neighs, - n_rings=n_rings, - delaunay=delaunay, - transform=transform, - set_diag=set_diag, - ) + return GridBuilder(**common, n_rings=n_rings, delaunay=delaunay) if delaunay: - return DelaunayBuilder( - radius=radius, - n_neighs=n_neighs, - transform=transform, - set_diag=set_diag, - percentile=percentile, - ) + return DelaunayBuilder(**common, radius=radius, percentile=percentile) if radius is not None: - return RadiusBuilder( - radius=radius, - n_neighs=n_neighs, - transform=transform, - set_diag=set_diag, - percentile=percentile, - ) - - return KNNBuilder( - n_neighs=n_neighs, - transform=transform, - set_diag=set_diag, - percentile=percentile, - ) + return RadiusBuilder(**common, radius=radius, percentile=percentile) + return KNNBuilder(**common, percentile=percentile) @d.dedent From 77755ed844266555d4c6b99bc87f8cb16ccc6b4f Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 2 Apr 2026 17:11:53 +0200 Subject: [PATCH 16/54] deprecate invalid n_neighs --- src/squidpy/gr/_build.py | 10 +++++----- src/squidpy/gr/neighbors.py | 11 ++++++++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index f61da6df5..d1060bef9 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -215,10 +215,10 @@ def spatial_neighbors( ``[min(radius), max(radius)]``. - Generic Delaunay mode: ``coord_type='generic'``, ``delaunay=True``. - Builds a Delaunay triangulation graph. ``n_neighs`` is not - used for the triangulation itself, but is still accepted for - backward compatibility. If ``radius`` is a tuple, it is used - only as a post-construction pruning interval. + Builds a Delaunay triangulation graph. ``n_neighs`` is + ignored by the triangulation and passing it is deprecated. + If ``radius`` is a tuple, it is used only as a + post-construction pruning interval. Across these modes: @@ -239,7 +239,7 @@ def spatial_neighbors( - If ``coord_type`` resolves to ``'grid'``, grid mode is used. In that case ``radius`` is ignored. - Otherwise, if ``delaunay=True``, Delaunay mode is used. - In this mode ``n_neighs`` does not affect the triangulation. + ``n_neighs`` is ignored (deprecated). A tuple ``radius`` is only used afterward as a pruning interval. A scalar ``radius`` is ignored. - Otherwise, if ``radius`` is set, radius mode is used. diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index 0c71aedc7..c65a3c7b8 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -181,11 +181,20 @@ class DelaunayBuilder(_RadiusFilterBuilder): def __init__( self, radius: float | tuple[float, float] | None = None, - n_neighs: int = 6, + n_neighs: int | None = None, transform: str | Transform | None = None, set_diag: bool = False, percentile: float | None = None, ) -> None: + if n_neighs is not None: + warnings.warn( + "Parameter `n_neighs` of `DelaunayBuilder` is ignored by " + "Delaunay triangulation and will be removed in squidpy v2.0.0.", + FutureWarning, + stacklevel=2, + ) + else: + n_neighs = 6 assert_positive(n_neighs, name="n_neighs") super().__init__(transform=transform, set_diag=set_diag, percentile=percentile) self.radius = radius From d8a81451663227aa5d8db362209474ebd551f2ee Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 2 Apr 2026 17:28:37 +0200 Subject: [PATCH 17/54] expose new functions and deprecate the flat one --- docs/api.md | 4 + src/squidpy/_docs.py | 30 +++ src/squidpy/gr/__init__.py | 8 + src/squidpy/gr/_build.py | 376 ++++++++++++++++++++++++++++++++----- 4 files changed, 373 insertions(+), 45 deletions(-) diff --git a/docs/api.md b/docs/api.md index 37610f75a..cfbc16256 100644 --- a/docs/api.md +++ b/docs/api.md @@ -18,6 +18,10 @@ import squidpy as sq :toctree: api gr.spatial_neighbors + gr.spatial_neighbors_knn + gr.spatial_neighbors_radius + gr.spatial_neighbors_delaunay + gr.spatial_neighbors_grid gr.SpatialNeighborsResult gr.mask_graph gr.nhood_enrichment diff --git a/src/squidpy/_docs.py b/src/squidpy/_docs.py index a4596df75..4580b34ef 100644 --- a/src/squidpy/_docs.py +++ b/src/squidpy/_docs.py @@ -359,6 +359,33 @@ def decorator2(obj: Any) -> Any: If multiple `library_id`, column in :attr:`anndata.AnnData.obs` which stores mapping between ``library_id`` and obs.""" +_sdata_params = """\ +elements_to_coordinate_systems + A dictionary mapping element names of the SpatialData object to coordinate systems. + The elements can be either Shapes or Labels. For compatibility, the spatialdata table must annotate + all regions keys. Must not be ``None`` if ``adata`` is a :class:`spatialdata.SpatialData`. +table_key + Key in :attr:`spatialdata.SpatialData.tables` where the spatialdata table is stored. Must not be ``None`` if + ``adata`` is a :class:`spatialdata.SpatialData`.""" +_graph_common_params = """\ +percentile + Percentile of the distances to use as threshold. +transform + Adjacency matrix transform (``'spectral'``, ``'cosine'``, or ``None``). +set_diag + Whether to set the diagonal of the connectivities to ``1.0``. +key_added + Key which controls where the results are saved if ``copy = False``.""" +_spatial_neighbors_returns = """\ +If ``copy = True``, returns a :class:`~squidpy.gr.SpatialNeighborsResult` with the +spatial connectivities and distances matrices. + +Otherwise, modifies the ``adata`` with the following keys: + + - :attr:`anndata.AnnData.obsp` ``['{key_added}_connectivities']`` - the spatial connectivities. + - :attr:`anndata.AnnData.obsp` ``['{key_added}_distances']`` - the spatial distances. + - :attr:`anndata.AnnData.uns` ``['{key_added}']`` - :class:`dict` containing parameters.""" + d = DocstringProcessor( adata=_adata, img_container=_img_container, @@ -401,4 +428,7 @@ def decorator2(obj: Any) -> Any: groups=_groups, plotting_library_id=_plotting_library_id, library_key=_library_key, + sdata_params=_sdata_params, + graph_common_params=_graph_common_params, + spatial_neighbors_returns=_spatial_neighbors_returns, ) diff --git a/src/squidpy/gr/__init__.py b/src/squidpy/gr/__init__.py index a64930622..a5dd26973 100644 --- a/src/squidpy/gr/__init__.py +++ b/src/squidpy/gr/__init__.py @@ -7,6 +7,10 @@ SpatialNeighborsResult, mask_graph, spatial_neighbors, + spatial_neighbors_delaunay, + spatial_neighbors_grid, + spatial_neighbors_knn, + spatial_neighbors_radius, ) from squidpy.gr._ligrec import ligrec from squidpy.gr._nhood import ( @@ -26,6 +30,10 @@ "neighbors", "mask_graph", "spatial_neighbors", + "spatial_neighbors_knn", + "spatial_neighbors_radius", + "spatial_neighbors_delaunay", + "spatial_neighbors_grid", "ligrec", "centrality_scores", "interaction_matrix", diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index d1060bef9..7572fad39 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -2,6 +2,7 @@ from __future__ import annotations +import warnings from functools import partial from typing import Any, NamedTuple, cast @@ -49,6 +50,10 @@ __all__ = [ "SpatialNeighborsResult", "spatial_neighbors", + "spatial_neighbors_knn", + "spatial_neighbors_radius", + "spatial_neighbors_delaunay", + "spatial_neighbors_grid", ] @@ -110,6 +115,66 @@ def _resolve_graph_builder( return KNNBuilder(**common, percentile=percentile) +def _resolve_spatial_data( + adata: AnnData | SpatialData, + *, + spatial_key: str, + elements_to_coordinate_systems: dict[str, str] | None, + table_key: str | None, + library_key: str | None, +) -> tuple[AnnData, str | None]: + """Resolve SpatialData to AnnData, returning (adata, library_key).""" + if isinstance(adata, SpatialData): + assert elements_to_coordinate_systems is not None, ( + "Since `adata` is a :class:`spatialdata.SpatialData`, `elements_to_coordinate_systems` must not be `None`." + ) + assert table_key is not None, ( + "Since `adata` is a :class:`spatialdata.SpatialData`, `table_key` must not be `None`." + ) + elements, table = match_element_to_table(adata, list(elements_to_coordinate_systems), table_key) + assert table.obs_names.equals(adata.tables[table_key].obs_names), ( + "The spatialdata table must annotate all elements keys. Some elements are missing, please check the `elements_to_coordinate_systems` dictionary." + ) + regions, region_key, instance_key = get_table_keys(adata.tables[table_key]) + regions = [regions] if isinstance(regions, str) else regions + ordered_regions_in_table = adata.tables[table_key].obs[region_key].unique() + + # TODO: remove this after https://github.com/scverse/spatialdata/issues/614 + remove_centroids = {} + elem_instances = [] + for e in regions: + schema = get_model(elements[e]) + element_instances = get_element_instances(elements[e]).to_series() + if np.isin(0, element_instances.values) and (schema in (Labels2DModel, Labels3DModel)): + element_instances = element_instances.drop(index=0) + remove_centroids[e] = True + else: + remove_centroids[e] = False + elem_instances.append(element_instances) + + element_instances = pd.concat(elem_instances) + if (not np.all(element_instances.values == adata.tables[table_key].obs[instance_key].values)) or ( + not np.all(ordered_regions_in_table == regions) + ): + raise ValueError( + "The spatialdata table must annotate all elements keys. Some elements are missing or not ordered correctly, please check the `elements_to_coordinate_systems` dictionary." + ) + centroids = [] + for region_ in ordered_regions_in_table: + cs = elements_to_coordinate_systems[region_] + centroid = get_centroids(adata[region_], coordinate_system=cs)[["x", "y"]].compute() + + # TODO: remove this after https://github.com/scverse/spatialdata/issues/614 + if remove_centroids[region_]: + centroid = centroid[1:].copy() + centroids.append(centroid) + + adata.tables[table_key].obsm[spatial_key] = np.concatenate(centroids) + adata = adata.tables[table_key] + library_key = region_key + return adata, library_key + + @d.dedent @inject_docs(t=Transform, c=CoordType) def spatial_neighbors( @@ -133,6 +198,18 @@ def spatial_neighbors( """ Create a graph from spatial coordinates. + .. deprecated:: 1.6.0 + The flat-parameter API of ``spatial_neighbors`` is deprecated and will + be removed in squidpy v1.7.0. Use one of the mode-specific functions + instead: + + - :func:`spatial_neighbors_knn` + - :func:`spatial_neighbors_radius` + - :func:`spatial_neighbors_delaunay` + - :func:`spatial_neighbors_grid` + + Passing a ``builder`` instance directly remains supported. + Parameters ---------- %(adata)s @@ -271,54 +348,59 @@ def spatial_neighbors( - :attr:`anndata.AnnData.obsp` ``['{{key_added}}_distances']`` - the spatial distances. - :attr:`anndata.AnnData.uns` ``['{{key_added}}']`` - :class:`dict` containing parameters. """ - if isinstance(adata, SpatialData): - assert elements_to_coordinate_systems is not None, ( - "Since `adata` is a :class:`spatialdata.SpatialData`, `elements_to_coordinate_systems` must not be `None`." - ) - assert table_key is not None, ( - "Since `adata` is a :class:`spatialdata.SpatialData`, `table_key` must not be `None`." - ) - elements, table = match_element_to_table(adata, list(elements_to_coordinate_systems), table_key) - assert table.obs_names.equals(adata.tables[table_key].obs_names), ( - "The spatialdata table must annotate all elements keys. Some elements are missing, please check the `elements_to_coordinate_systems` dictionary." + if builder is None: + warnings.warn( + "Calling `spatial_neighbors` without a `builder` argument is deprecated " + "and will be removed in squidpy v1.7.0. Use one of the mode-specific " + "functions instead: `spatial_neighbors_knn`, `spatial_neighbors_radius`, " + "`spatial_neighbors_delaunay`, or `spatial_neighbors_grid`.", + FutureWarning, + stacklevel=2, ) - regions, region_key, instance_key = get_table_keys(adata.tables[table_key]) - regions = [regions] if isinstance(regions, str) else regions - ordered_regions_in_table = adata.tables[table_key].obs[region_key].unique() - - # TODO: remove this after https://github.com/scverse/spatialdata/issues/614 - remove_centroids = {} - elem_instances = [] - for e in regions: - schema = get_model(elements[e]) - element_instances = get_element_instances(elements[e]).to_series() - if np.isin(0, element_instances.values) and (schema in (Labels2DModel, Labels3DModel)): - element_instances = element_instances.drop(index=0) - remove_centroids[e] = True - else: - remove_centroids[e] = False - elem_instances.append(element_instances) - - element_instances = pd.concat(elem_instances) - if (not np.all(element_instances.values == adata.tables[table_key].obs[instance_key].values)) or ( - not np.all(ordered_regions_in_table == regions) - ): - raise ValueError( - "The spatialdata table must annotate all elements keys. Some elements are missing or not ordered correctly, please check the `elements_to_coordinate_systems` dictionary." - ) - centroids = [] - for region_ in ordered_regions_in_table: - cs = elements_to_coordinate_systems[region_] - centroid = get_centroids(adata[region_], coordinate_system=cs)[["x", "y"]].compute() + return _spatial_neighbors( + adata, + spatial_key=spatial_key, + elements_to_coordinate_systems=elements_to_coordinate_systems, + table_key=table_key, + library_key=library_key, + coord_type=coord_type, + n_neighs=n_neighs, + radius=radius, + delaunay=delaunay, + n_rings=n_rings, + percentile=percentile, + transform=transform, + set_diag=set_diag, + builder=builder, + key_added=key_added, + copy=copy, + ) - # TODO: remove this after https://github.com/scverse/spatialdata/issues/614 - if remove_centroids[region_]: - centroid = centroid[1:].copy() - centroids.append(centroid) - adata.tables[table_key].obsm[spatial_key] = np.concatenate(centroids) - adata = adata.tables[table_key] - library_key = region_key +def _spatial_neighbors( + adata: AnnData | SpatialData, + *, + spatial_key: str = Key.obsm.spatial, + elements_to_coordinate_systems: dict[str, str] | None = None, + table_key: str | None = None, + library_key: str | None = None, + coord_type: str | CoordType | None = None, + n_neighs: int | None = None, + radius: float | tuple[float, float] | None = None, + delaunay: bool | None = None, + n_rings: int | None = None, + percentile: float | None = None, + transform: str | Transform | None = None, + set_diag: bool | None = None, + builder: GraphBuilder | None = None, + key_added: str = "spatial", + copy: bool = False, +) -> SpatialNeighborsResult | None: + """Internal implementation of spatial_neighbors (no deprecation warning).""" + adata, library_key = _resolve_spatial_data( + adata, spatial_key=spatial_key, elements_to_coordinate_systems=elements_to_coordinate_systems, + table_key=table_key, library_key=library_key, + ) _assert_spatial_basis(adata, spatial_key) @@ -354,6 +436,210 @@ def spatial_neighbors( has_spatial_uns=Key.uns.spatial in adata.uns, ) + return _run_spatial_neighbors(adata, builder=builder, spatial_key=spatial_key, library_key=library_key, key_added=key_added, copy=copy) + + +@d.dedent +def spatial_neighbors_knn( + adata: AnnData | SpatialData, + spatial_key: str = Key.obsm.spatial, + elements_to_coordinate_systems: dict[str, str] | None = None, + table_key: str | None = None, + library_key: str | None = None, + n_neighs: int = 6, + percentile: float | None = None, + transform: str | Transform | None = None, + set_diag: bool = False, + key_added: str = "spatial", + copy: bool = False, +) -> SpatialNeighborsResult | None: + """Create a k-nearest-neighbor graph from spatial coordinates. + + Parameters + ---------- + %(adata)s + %(spatial_key)s + %(sdata_params)s + %(library_key)s + n_neighs + Number of nearest neighbors. Defaults to ``6``. + %(graph_common_params)s + %(copy)s + + Returns + ------- + %(spatial_neighbors_returns)s + """ + transform_enum = Transform.NONE if transform is None else Transform(transform) + builder = KNNBuilder( + n_neighs=n_neighs, percentile=percentile, + transform=transform_enum, set_diag=set_diag, + ) + return _spatial_neighbors( + adata, spatial_key=spatial_key, + elements_to_coordinate_systems=elements_to_coordinate_systems, + table_key=table_key, library_key=library_key, + builder=builder, key_added=key_added, copy=copy, + ) + + +@d.dedent +def spatial_neighbors_radius( + adata: AnnData | SpatialData, + spatial_key: str = Key.obsm.spatial, + elements_to_coordinate_systems: dict[str, str] | None = None, + table_key: str | None = None, + library_key: str | None = None, + radius: float | tuple[float, float] = 1.0, + n_neighs: int = 6, + percentile: float | None = None, + transform: str | Transform | None = None, + set_diag: bool = False, + key_added: str = "spatial", + copy: bool = False, +) -> SpatialNeighborsResult | None: + """Create a radius-based graph from spatial coordinates. + + Parameters + ---------- + %(adata)s + %(spatial_key)s + %(sdata_params)s + %(library_key)s + radius + Neighborhood radius. If a :class:`tuple`, the graph is built with the + maximum radius and then pruned to the interval ``[min(radius), max(radius)]``. + n_neighs + Number of nearest neighbors used internally by the radius graph builder. + Defaults to ``6``. + %(graph_common_params)s + %(copy)s + + Returns + ------- + %(spatial_neighbors_returns)s + """ + transform_enum = Transform.NONE if transform is None else Transform(transform) + builder = RadiusBuilder( + n_neighs=n_neighs, radius=radius, percentile=percentile, + transform=transform_enum, set_diag=set_diag, + ) + return _spatial_neighbors( + adata, spatial_key=spatial_key, + elements_to_coordinate_systems=elements_to_coordinate_systems, + table_key=table_key, library_key=library_key, + builder=builder, key_added=key_added, copy=copy, + ) + + +@d.dedent +def spatial_neighbors_delaunay( + adata: AnnData | SpatialData, + spatial_key: str = Key.obsm.spatial, + elements_to_coordinate_systems: dict[str, str] | None = None, + table_key: str | None = None, + library_key: str | None = None, + radius: tuple[float, float] | None = None, + percentile: float | None = None, + transform: str | Transform | None = None, + set_diag: bool = False, + key_added: str = "spatial", + copy: bool = False, +) -> SpatialNeighborsResult | None: + """Create a Delaunay triangulation graph from spatial coordinates. + + Parameters + ---------- + %(adata)s + %(spatial_key)s + %(sdata_params)s + %(library_key)s + radius + If a :class:`tuple`, used as a post-construction pruning interval + ``[min(radius), max(radius)]``. + %(graph_common_params)s + %(copy)s + + Returns + ------- + %(spatial_neighbors_returns)s + """ + transform_enum = Transform.NONE if transform is None else Transform(transform) + builder = DelaunayBuilder( + radius=radius, percentile=percentile, + transform=transform_enum, set_diag=set_diag, + ) + return _spatial_neighbors( + adata, spatial_key=spatial_key, + elements_to_coordinate_systems=elements_to_coordinate_systems, + table_key=table_key, library_key=library_key, + builder=builder, key_added=key_added, copy=copy, + ) + + +@d.dedent +def spatial_neighbors_grid( + adata: AnnData | SpatialData, + spatial_key: str = Key.obsm.spatial, + elements_to_coordinate_systems: dict[str, str] | None = None, + table_key: str | None = None, + library_key: str | None = None, + n_neighs: int = 6, + n_rings: int = 1, + delaunay: bool = False, + transform: str | Transform | None = None, + set_diag: bool = False, + key_added: str = "spatial", + copy: bool = False, +) -> SpatialNeighborsResult | None: + """Create a grid-based graph from spatial coordinates. + + This is the mode used for Visium-like grid coordinates. + + Parameters + ---------- + %(adata)s + %(spatial_key)s + %(sdata_params)s + %(library_key)s + n_neighs + Number of neighboring tiles. Defaults to ``6``. + n_rings + Number of rings of neighbors. Defaults to ``1``. + delaunay + Whether to compute the grid graph from Delaunay triangulation. + %(graph_common_params)s + %(copy)s + + Returns + ------- + %(spatial_neighbors_returns)s + """ + assert_positive(n_rings, name="n_rings") + assert_positive(n_neighs, name="n_neighs") + transform_enum = Transform.NONE if transform is None else Transform(transform) + builder = GridBuilder( + n_neighs=n_neighs, n_rings=n_rings, delaunay=delaunay, + transform=transform_enum, set_diag=set_diag, + ) + return _spatial_neighbors( + adata, spatial_key=spatial_key, + elements_to_coordinate_systems=elements_to_coordinate_systems, + table_key=table_key, library_key=library_key, + builder=builder, key_added=key_added, copy=copy, + ) + + +def _run_spatial_neighbors( + adata: AnnData, + *, + builder: GraphBuilder, + spatial_key: str = Key.obsm.spatial, + library_key: str | None = None, + key_added: str = "spatial", + copy: bool = False, +) -> SpatialNeighborsResult | None: + """Shared core: build the graph from a resolved builder and save results.""" if library_key is not None: _assert_categorical_obs(adata, key=library_key) libs = adata.obs[library_key].cat.categories From 2e75b3f3413513711fef56addf49d9b7a4f6c464 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 2 Apr 2026 17:40:49 +0200 Subject: [PATCH 18/54] move branches to classes --- src/squidpy/gr/neighbors.py | 239 +++++++++++--------------- tests/graph/test_spatial_neighbors.py | 3 +- 2 files changed, 99 insertions(+), 143 deletions(-) diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index c65a3c7b8..67d8089d6 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -120,15 +120,23 @@ def coord_type(self) -> CoordType: return CoordType.GENERIC def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: - return cast( - tuple[csr_matrix, csr_matrix], - _build_connectivity( - coords, - n_neighs=self.n_neighs, - return_distance=True, - set_diag=self.set_diag, - ), + N = coords.shape[0] + tree = NearestNeighbors(n_neighbors=self.n_neighs, radius=1, metric="euclidean") + tree.fit(coords) + + dists, col_indices = tree.kneighbors() + dists, col_indices = dists.reshape(-1), col_indices.reshape(-1) + row_indices = np.repeat(np.arange(N), self.n_neighs) + + Adj = csr_matrix( + (np.ones_like(row_indices, dtype=np.float32), (row_indices, col_indices)), + shape=(N, N), ) + Dst = csr_matrix((dists, (row_indices, col_indices)), shape=(N, N)) + + Adj.setdiag(1.0 if self.set_diag else Adj.diagonal()) + Dst.setdiag(0.0) + return Adj, Dst class _RadiusFilterBuilder(GraphBuilder): @@ -163,16 +171,25 @@ def __init__( self.n_neighs = n_neighs def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: - return cast( - tuple[csr_matrix, csr_matrix], - _build_connectivity( - coords, - n_neighs=self.n_neighs, - radius=self.radius, - return_distance=True, - set_diag=self.set_diag, - ), + N = coords.shape[0] + r = self.radius if isinstance(self.radius, int | float) else max(self.radius) + tree = NearestNeighbors(n_neighbors=self.n_neighs, radius=r, metric="euclidean") + tree.fit(coords) + + dists, col_indices = tree.radius_neighbors() + row_indices = np.repeat(np.arange(N), [len(x) for x in col_indices]) + dists = np.concatenate(dists) + col_indices = np.concatenate(col_indices) + + Adj = csr_matrix( + (np.ones_like(row_indices, dtype=np.float32), (row_indices, col_indices)), + shape=(N, N), ) + Dst = csr_matrix((dists, (row_indices, col_indices)), shape=(N, N)) + + Adj.setdiag(1.0 if self.set_diag else Adj.diagonal()) + Dst.setdiag(0.0) + return Adj, Dst class DelaunayBuilder(_RadiusFilterBuilder): @@ -201,17 +218,23 @@ def __init__( self.n_neighs = n_neighs def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: - return cast( - tuple[csr_matrix, csr_matrix], - _build_connectivity( - coords, - n_neighs=self.n_neighs, - radius=self.radius, - delaunay=True, - return_distance=True, - set_diag=self.set_diag, - ), - ) + N = coords.shape[0] + tri = Delaunay(coords) + indptr, indices = tri.vertex_neighbor_vertices + Adj = csr_matrix((np.ones_like(indices, dtype=np.float32), indices, indptr), shape=(N, N)) + + # fmt: off + dists = np.array(list(chain(*( + euclidean_distances(coords[indices[indptr[i] : indptr[i + 1]], :], coords[np.newaxis, i, :]) + for i in range(N) + if len(indices[indptr[i] : indptr[i + 1]]) + )))).squeeze() + # fmt: on + Dst = csr_matrix((dists, indices, indptr), shape=(N, N)) + + Adj.setdiag(1.0 if self.set_diag else Adj.diagonal()) + Dst.setdiag(0.0) + return Adj, Dst class GridBuilder(GraphBuilder): @@ -237,13 +260,53 @@ def coord_type(self) -> CoordType: return CoordType.GRID def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: - return _build_grid( - coords, - n_neighs=self.n_neighs, - n_rings=self.n_rings, - delaunay=self.delaunay, - set_diag=self.set_diag, - ) + if self.n_rings > 1: + Adj = self._base_adjacency(coords, set_diag=True) + Res, Walk = Adj, Adj + for i in range(self.n_rings - 1): + Walk = Walk @ Adj + Walk[Res.nonzero()] = 0.0 + Walk.eliminate_zeros() + Walk.data[:] = i + 2.0 + Res = Res + Walk + Adj = Res + Adj.setdiag(float(self.set_diag)) + Adj.eliminate_zeros() + + Dst = Adj.copy() + Adj.data[:] = 1.0 + else: + Adj = self._base_adjacency(coords, set_diag=self.set_diag) + Dst = Adj.copy() + + Dst.setdiag(0.0) + return Adj, Dst + + def _base_adjacency(self, coords: NDArrayA, *, set_diag: bool) -> csr_matrix: + """KNN adjacency with median-distance correction for grid coordinates.""" + N = coords.shape[0] + if self.delaunay: + tri = Delaunay(coords) + indptr, indices = tri.vertex_neighbor_vertices + Adj = csr_matrix((np.ones_like(indices, dtype=np.float32), indices, indptr), shape=(N, N)) + else: + tree = NearestNeighbors(n_neighbors=self.n_neighs, radius=1, metric="euclidean") + tree.fit(coords) + dists, col_indices = tree.kneighbors() + dists, col_indices = dists.reshape(-1), col_indices.reshape(-1) + row_indices = np.repeat(np.arange(N), self.n_neighs) + + dist_cutoff = np.median(dists) * 1.3 + mask = dists < dist_cutoff + row_indices, col_indices = row_indices[mask], col_indices[mask] + + Adj = csr_matrix( + (np.ones_like(row_indices, dtype=np.float32), (row_indices, col_indices)), + shape=(N, N), + ) + + Adj.setdiag(1.0 if set_diag else Adj.diagonal()) + return Adj # --------------------------------------------------------------------------- @@ -265,112 +328,6 @@ def _filter_by_radius_interval( Adj.setdiag(a_diag) -def _build_grid( - coords: NDArrayA, - n_neighs: int, - n_rings: int, - delaunay: bool = False, - set_diag: bool = False, -) -> tuple[csr_matrix, csr_matrix]: - if n_rings > 1: - Adj: csr_matrix = _build_connectivity( - coords, - n_neighs=n_neighs, - neigh_correct=True, - set_diag=True, - delaunay=delaunay, - return_distance=False, - ) - Res, Walk = Adj, Adj - for i in range(n_rings - 1): - Walk = Walk @ Adj - Walk[Res.nonzero()] = 0.0 - Walk.eliminate_zeros() - Walk.data[:] = i + 2.0 - Res = Res + Walk - Adj = Res - Adj.setdiag(float(set_diag)) - Adj.eliminate_zeros() - - Dst = Adj.copy() - Adj.data[:] = 1.0 - else: - Adj = _build_connectivity( - coords, - n_neighs=n_neighs, - neigh_correct=True, - delaunay=delaunay, - set_diag=set_diag, - ) - Dst = Adj.copy() - - Dst.setdiag(0.0) - - return Adj, Dst - - -def _build_connectivity( - coords: NDArrayA, - n_neighs: int, - radius: float | tuple[float, float] | None = None, - delaunay: bool = False, - neigh_correct: bool = False, - set_diag: bool = False, - return_distance: bool = False, -) -> csr_matrix | tuple[csr_matrix, csr_matrix]: - N = coords.shape[0] - if delaunay: - tri = Delaunay(coords) - indptr, indices = tri.vertex_neighbor_vertices - Adj = csr_matrix((np.ones_like(indices, dtype=np.float32), indices, indptr), shape=(N, N)) - - if return_distance: - # fmt: off - dists = np.array(list(chain(*( - euclidean_distances(coords[indices[indptr[i] : indptr[i + 1]], :], coords[np.newaxis, i, :]) - for i in range(N) - if len(indices[indptr[i] : indptr[i + 1]]) - )))).squeeze() - Dst = csr_matrix((dists, indices, indptr), shape=(N, N)) - # fmt: on - else: - r = 1 if radius is None else radius if isinstance(radius, int | float) else max(radius) - tree = NearestNeighbors(n_neighbors=n_neighs, radius=r, metric="euclidean") - tree.fit(coords) - - if radius is None: - dists, col_indices = tree.kneighbors() - dists, col_indices = dists.reshape(-1), col_indices.reshape(-1) - row_indices = np.repeat(np.arange(N), n_neighs) - if neigh_correct: - dist_cutoff = np.median(dists) * 1.3 # there's a small amount of sway - mask = dists < dist_cutoff - row_indices, col_indices, dists = ( - row_indices[mask], - col_indices[mask], - dists[mask], - ) - else: - dists, col_indices = tree.radius_neighbors() - row_indices = np.repeat(np.arange(N), [len(x) for x in col_indices]) - dists = np.concatenate(dists) - col_indices = np.concatenate(col_indices) - - Adj = csr_matrix( - (np.ones_like(row_indices, dtype=np.float32), (row_indices, col_indices)), - shape=(N, N), - ) - if return_distance: - Dst = csr_matrix((dists, (row_indices, col_indices)), shape=(N, N)) - - # radius-based filtering needs same indices/indptr: do not remove 0s - Adj.setdiag(1.0 if set_diag else Adj.diagonal()) - if return_distance: - Dst.setdiag(0.0) - return Adj, Dst - - return Adj - @njit def _csr_bilateral_diag_scale_helper( diff --git a/tests/graph/test_spatial_neighbors.py b/tests/graph/test_spatial_neighbors.py index 8cf32c2c5..1f6bdbd03 100644 --- a/tests/graph/test_spatial_neighbors.py +++ b/tests/graph/test_spatial_neighbors.py @@ -18,7 +18,6 @@ GridBuilder, KNNBuilder, RadiusBuilder, - _build_connectivity, ) from squidpy.gr.neighbors import ( KNNBuilder as PublicKNNBuilder, @@ -305,7 +304,7 @@ def test_percentile_filtering(self, adata_hne: AnnData, percentile: float, coord assert not ((result.connectivities != result_filtered.connectivities).nnz == 0) assert result.distances.max() > result_filtered.distances.max() - Adj, Dst = _build_connectivity(adata_hne.obsm["spatial"], n_neighs=6, return_distance=True, set_diag=False) + Adj, Dst = KNNBuilder(n_neighs=6, set_diag=False)._build_graph(adata_hne.obsm["spatial"]) threshold = np.percentile(Dst.data, percentile) Adj[Dst > threshold] = 0.0 Dst[Dst > threshold] = 0.0 From 8c1c2ef94c474d142f5b40801c6ef7f889f8884a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:41:01 +0000 Subject: [PATCH 19/54] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/squidpy/gr/_build.py | 77 ++++++++++++++++++++++++++----------- src/squidpy/gr/neighbors.py | 1 - 2 files changed, 54 insertions(+), 24 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 7572fad39..203ecc0d7 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -398,8 +398,11 @@ def _spatial_neighbors( ) -> SpatialNeighborsResult | None: """Internal implementation of spatial_neighbors (no deprecation warning).""" adata, library_key = _resolve_spatial_data( - adata, spatial_key=spatial_key, elements_to_coordinate_systems=elements_to_coordinate_systems, - table_key=table_key, library_key=library_key, + adata, + spatial_key=spatial_key, + elements_to_coordinate_systems=elements_to_coordinate_systems, + table_key=table_key, + library_key=library_key, ) _assert_spatial_basis(adata, spatial_key) @@ -436,7 +439,9 @@ def _spatial_neighbors( has_spatial_uns=Key.uns.spatial in adata.uns, ) - return _run_spatial_neighbors(adata, builder=builder, spatial_key=spatial_key, library_key=library_key, key_added=key_added, copy=copy) + return _run_spatial_neighbors( + adata, builder=builder, spatial_key=spatial_key, library_key=library_key, key_added=key_added, copy=copy + ) @d.dedent @@ -472,14 +477,20 @@ def spatial_neighbors_knn( """ transform_enum = Transform.NONE if transform is None else Transform(transform) builder = KNNBuilder( - n_neighs=n_neighs, percentile=percentile, - transform=transform_enum, set_diag=set_diag, + n_neighs=n_neighs, + percentile=percentile, + transform=transform_enum, + set_diag=set_diag, ) return _spatial_neighbors( - adata, spatial_key=spatial_key, + adata, + spatial_key=spatial_key, elements_to_coordinate_systems=elements_to_coordinate_systems, - table_key=table_key, library_key=library_key, - builder=builder, key_added=key_added, copy=copy, + table_key=table_key, + library_key=library_key, + builder=builder, + key_added=key_added, + copy=copy, ) @@ -521,14 +532,21 @@ def spatial_neighbors_radius( """ transform_enum = Transform.NONE if transform is None else Transform(transform) builder = RadiusBuilder( - n_neighs=n_neighs, radius=radius, percentile=percentile, - transform=transform_enum, set_diag=set_diag, + n_neighs=n_neighs, + radius=radius, + percentile=percentile, + transform=transform_enum, + set_diag=set_diag, ) return _spatial_neighbors( - adata, spatial_key=spatial_key, + adata, + spatial_key=spatial_key, elements_to_coordinate_systems=elements_to_coordinate_systems, - table_key=table_key, library_key=library_key, - builder=builder, key_added=key_added, copy=copy, + table_key=table_key, + library_key=library_key, + builder=builder, + key_added=key_added, + copy=copy, ) @@ -566,14 +584,20 @@ def spatial_neighbors_delaunay( """ transform_enum = Transform.NONE if transform is None else Transform(transform) builder = DelaunayBuilder( - radius=radius, percentile=percentile, - transform=transform_enum, set_diag=set_diag, + radius=radius, + percentile=percentile, + transform=transform_enum, + set_diag=set_diag, ) return _spatial_neighbors( - adata, spatial_key=spatial_key, + adata, + spatial_key=spatial_key, elements_to_coordinate_systems=elements_to_coordinate_systems, - table_key=table_key, library_key=library_key, - builder=builder, key_added=key_added, copy=copy, + table_key=table_key, + library_key=library_key, + builder=builder, + key_added=key_added, + copy=copy, ) @@ -619,14 +643,21 @@ def spatial_neighbors_grid( assert_positive(n_neighs, name="n_neighs") transform_enum = Transform.NONE if transform is None else Transform(transform) builder = GridBuilder( - n_neighs=n_neighs, n_rings=n_rings, delaunay=delaunay, - transform=transform_enum, set_diag=set_diag, + n_neighs=n_neighs, + n_rings=n_rings, + delaunay=delaunay, + transform=transform_enum, + set_diag=set_diag, ) return _spatial_neighbors( - adata, spatial_key=spatial_key, + adata, + spatial_key=spatial_key, elements_to_coordinate_systems=elements_to_coordinate_systems, - table_key=table_key, library_key=library_key, - builder=builder, key_added=key_added, copy=copy, + table_key=table_key, + library_key=library_key, + builder=builder, + key_added=key_added, + copy=copy, ) diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index 67d8089d6..a4726ce85 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -328,7 +328,6 @@ def _filter_by_radius_interval( Adj.setdiag(a_diag) - @njit def _csr_bilateral_diag_scale_helper( mat: csr_array | csr_matrix, From 268d30cb9e88b97a5e391e0696871504614b6b38 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 2 Apr 2026 17:42:42 +0200 Subject: [PATCH 20/54] remove radius abstraction --- src/squidpy/gr/neighbors.py | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index 67d8089d6..8606dd644 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -139,22 +139,7 @@ def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: return Adj, Dst -class _RadiusFilterBuilder(GraphBuilder): - """Intermediate base for builders that support radius-interval pruning.""" - - radius: float | tuple[float, float] | None - n_neighs: int - - @property - def coord_type(self) -> CoordType: - return CoordType.GENERIC - - def _apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: - if isinstance(self.radius, Iterable): - _filter_by_radius_interval(Adj, Dst, self.radius) - - -class RadiusBuilder(_RadiusFilterBuilder): +class RadiusBuilder(GraphBuilder): """Build a generic radius-based spatial graph.""" def __init__( @@ -170,6 +155,10 @@ def __init__( self.radius = radius self.n_neighs = n_neighs + @property + def coord_type(self) -> CoordType: + return CoordType.GENERIC + def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: N = coords.shape[0] r = self.radius if isinstance(self.radius, int | float) else max(self.radius) @@ -191,8 +180,12 @@ def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: Dst.setdiag(0.0) return Adj, Dst + def _apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: + if isinstance(self.radius, Iterable): + _filter_by_radius_interval(Adj, Dst, self.radius) -class DelaunayBuilder(_RadiusFilterBuilder): + +class DelaunayBuilder(GraphBuilder): """Build a generic spatial graph from a Delaunay triangulation.""" def __init__( @@ -217,6 +210,10 @@ def __init__( self.radius = radius self.n_neighs = n_neighs + @property + def coord_type(self) -> CoordType: + return CoordType.GENERIC + def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: N = coords.shape[0] tri = Delaunay(coords) @@ -236,6 +233,10 @@ def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: Dst.setdiag(0.0) return Adj, Dst + def _apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: + if isinstance(self.radius, Iterable): + _filter_by_radius_interval(Adj, Dst, self.radius) + class GridBuilder(GraphBuilder): """Build a grid-based spatial graph.""" From aa9734ab0bb6ae446da7ab6fd363d8a1c3339b22 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 2 Apr 2026 17:47:33 +0200 Subject: [PATCH 21/54] add extensibility page --- docs/api.md | 3 ++ docs/extensibility.md | 65 +++++++++++++++++++++++++++++++++++++ docs/index.md | 1 + src/squidpy/gr/neighbors.py | 18 ++-------- 4 files changed, 71 insertions(+), 16 deletions(-) create mode 100644 docs/extensibility.md diff --git a/docs/api.md b/docs/api.md index cfbc16256..60434e4be 100644 --- a/docs/api.md +++ b/docs/api.md @@ -116,6 +116,9 @@ import squidpy as sq ## Extensibility + +See the {doc}`extensibility guide ` for how to implement a custom graph builder. + ```{eval-rst} .. module:: squidpy.gr.neighbors .. currentmodule:: squidpy diff --git a/docs/extensibility.md b/docs/extensibility.md new file mode 100644 index 000000000..b6605f61b --- /dev/null +++ b/docs/extensibility.md @@ -0,0 +1,65 @@ +# Extensibility + +## Custom graph builders + +The `squidpy.gr.neighbors` module exposes a {class}`~squidpy.gr.neighbors.GraphBuilder` +base class that all built-in graph construction strategies inherit from. +You can implement your own strategy by subclassing it. + +### Minimal example + +```python +import numpy as np +from scipy.sparse import csr_matrix +from squidpy.gr.neighbors import GraphBuilder +from squidpy._constants._constants import CoordType + + +class MyBuilder(GraphBuilder): + """Example: connect every point to its single nearest neighbor.""" + + @property + def coord_type(self) -> CoordType: + return CoordType.GENERIC + + def _build_graph(self, coords): + from sklearn.neighbors import NearestNeighbors + + N = coords.shape[0] + tree = NearestNeighbors(n_neighbors=2, metric="euclidean") + tree.fit(coords) + dists, indices = tree.kneighbors() + + # drop self-neighbor (index 0), keep only the closest other point + row = np.arange(N) + col = indices[:, 1] + d = dists[:, 1] + + Adj = csr_matrix((np.ones(N, dtype=np.float32), (row, col)), shape=(N, N)) + Dst = csr_matrix((d, (row, col)), shape=(N, N)) + return Adj, Dst +``` + +### Using a custom builder + +Pass an instance to any of the spatial-neighbor functions via the ``builder`` +argument: + +```python +import squidpy as sq + +sq.gr.spatial_neighbors_knn(adata) # built-in KNN mode +sq.gr.spatial_neighbors(adata, builder=MyBuilder()) # custom builder +``` + +### What to override + +| Method / property | Required | Purpose | +|---|---|---| +| {attr}`~squidpy.gr.neighbors.GraphBuilder.coord_type` | yes | Return the {class}`~squidpy._constants._constants.CoordType` this builder supports. | +| {meth}`~squidpy.gr.neighbors.GraphBuilder._build_graph` | yes | Construct and return ``(Adj, Dst)`` as {class}`~scipy.sparse.csr_matrix` pair. | +| {meth}`~squidpy.gr.neighbors.GraphBuilder._apply_filters` | no | Post-processing on the raw ``Adj``/``Dst`` (e.g. radius-interval pruning). Called before percentile filtering and transform. | + +The base class handles percentile filtering ({meth}`~squidpy.gr.neighbors.GraphBuilder._apply_percentile`), +adjacency transforms ({meth}`~squidpy.gr.neighbors.GraphBuilder._apply_transform`), and +{class}`~scipy.sparse.SparseEfficiencyWarning` suppression automatically. diff --git a/docs/index.md b/docs/index.md index e44bc2995..7ddaff4d7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -63,6 +63,7 @@ We are happy about any contributions! Before you start, check out our [contribut installation api classes + extensibility release_notes references contributing diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index 886ec0659..d167aabbc 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -1,20 +1,6 @@ -"""Graph construction strategies for :func:`squidpy.gr.spatial_neighbors`. +"""Graph construction strategies for spatial neighbor graphs. -This module provides the builder classes that control how spatial neighbor -graphs are constructed. :func:`~squidpy.gr.spatial_neighbors` accepts a -``builder`` argument; when supplied, the builder fully determines the -graph-construction algorithm and its parameters. - -Built-in builders cover the common cases: - -- :class:`KNNBuilder` -- k-nearest-neighbor graph. -- :class:`RadiusBuilder` -- radius-based graph. -- :class:`DelaunayBuilder` -- Delaunay triangulation graph. -- :class:`GridBuilder` -- grid (Visium-style) graph. - -To implement a custom strategy, subclass :class:`GraphBuilder` and override -:meth:`~GraphBuilder._build_graph`. The returned object can then be passed -to ``spatial_neighbors(adata, builder=my_builder)``. +See the :doc:`/extensibility` guide for how to implement a custom builder. """ from __future__ import annotations From 67bad43323b553da20fc69c546cb61a8cc26b821 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 2 Apr 2026 17:53:46 +0200 Subject: [PATCH 22/54] unprivate methods and add extensibility page --- docs/extensibility.md | 73 +++++++++++++++------------ src/squidpy/gr/neighbors.py | 20 ++++---- tests/graph/test_spatial_neighbors.py | 2 +- 3 files changed, 53 insertions(+), 42 deletions(-) diff --git a/docs/extensibility.md b/docs/extensibility.md index b6605f61b..1ea9b0704 100644 --- a/docs/extensibility.md +++ b/docs/extensibility.md @@ -6,60 +6,71 @@ The `squidpy.gr.neighbors` module exposes a {class}`~squidpy.gr.neighbors.GraphB base class that all built-in graph construction strategies inherit from. You can implement your own strategy by subclassing it. -### Minimal example +### What to override + +| Method / property | Required | Purpose | +|---|---|---| +| {attr}`~squidpy.gr.neighbors.GraphBuilder.coord_type` | yes | Return the {class}`~squidpy._constants._constants.CoordType` this builder supports. | +| {meth}`~squidpy.gr.neighbors.GraphBuilder.build_graph` | yes | Construct and return ``(Adj, Dst)`` as {class}`~scipy.sparse.csr_matrix` pair. | +| {meth}`~squidpy.gr.neighbors.GraphBuilder.apply_filters` | no | Post-processing on the raw ``Adj``/``Dst`` (e.g. radius-interval pruning). Called before percentile filtering and transform. | + +The base class handles percentile filtering, adjacency transforms, and +{class}`~scipy.sparse.SparseEfficiencyWarning` suppression automatically. + +### Example: mutual KNN + +The built-in {class}`~squidpy.gr.neighbors.KNNBuilder` keeps all k-nearest +edges regardless of reciprocity. A mutual KNN graph retains an edge only when +both endpoints consider each other a neighbor, producing a sparser and more +conservative graph: ```python import numpy as np from scipy.sparse import csr_matrix -from squidpy.gr.neighbors import GraphBuilder +from sklearn.neighbors import NearestNeighbors + from squidpy._constants._constants import CoordType +from squidpy.gr.neighbors import GraphBuilder + +class MutualKNNBuilder(GraphBuilder): + """KNN graph keeping only mutual (reciprocal) edges.""" -class MyBuilder(GraphBuilder): - """Example: connect every point to its single nearest neighbor.""" + def __init__(self, n_neighs: int = 6, **kwargs): + super().__init__(**kwargs) + self.n_neighs = n_neighs @property def coord_type(self) -> CoordType: return CoordType.GENERIC - def _build_graph(self, coords): - from sklearn.neighbors import NearestNeighbors - + def build_graph(self, coords): N = coords.shape[0] - tree = NearestNeighbors(n_neighbors=2, metric="euclidean") + tree = NearestNeighbors(n_neighbors=self.n_neighs, metric="euclidean") tree.fit(coords) dists, indices = tree.kneighbors() - # drop self-neighbor (index 0), keep only the closest other point - row = np.arange(N) - col = indices[:, 1] - d = dists[:, 1] + row = np.repeat(np.arange(N), self.n_neighs) + col = indices.reshape(-1) + d = dists.reshape(-1) - Adj = csr_matrix((np.ones(N, dtype=np.float32), (row, col)), shape=(N, N)) + Adj = csr_matrix((np.ones_like(d, dtype=np.float32), (row, col)), shape=(N, N)) Dst = csr_matrix((d, (row, col)), shape=(N, N)) + + # keep only mutual edges + mutual = Adj.multiply(Adj.T) + Adj = mutual.tocsr() + Dst = Dst.multiply(mutual).tocsr() + + Adj.setdiag(1.0 if self.set_diag else Adj.diagonal()) + Dst.setdiag(0.0) return Adj, Dst ``` -### Using a custom builder - -Pass an instance to any of the spatial-neighbor functions via the ``builder`` -argument: +Use it like any other builder: ```python import squidpy as sq -sq.gr.spatial_neighbors_knn(adata) # built-in KNN mode -sq.gr.spatial_neighbors(adata, builder=MyBuilder()) # custom builder +sq.gr.spatial_neighbors(adata, builder=MutualKNNBuilder(n_neighs=6)) ``` - -### What to override - -| Method / property | Required | Purpose | -|---|---|---| -| {attr}`~squidpy.gr.neighbors.GraphBuilder.coord_type` | yes | Return the {class}`~squidpy._constants._constants.CoordType` this builder supports. | -| {meth}`~squidpy.gr.neighbors.GraphBuilder._build_graph` | yes | Construct and return ``(Adj, Dst)`` as {class}`~scipy.sparse.csr_matrix` pair. | -| {meth}`~squidpy.gr.neighbors.GraphBuilder._apply_filters` | no | Post-processing on the raw ``Adj``/``Dst`` (e.g. radius-interval pruning). Called before percentile filtering and transform. | - -The base class handles percentile filtering ({meth}`~squidpy.gr.neighbors.GraphBuilder._apply_percentile`), -adjacency transforms ({meth}`~squidpy.gr.neighbors.GraphBuilder._apply_transform`), and -{class}`~scipy.sparse.SparseEfficiencyWarning` suppression automatically. diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index d167aabbc..9a15ef3b8 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -53,9 +53,9 @@ def coord_type(self) -> CoordType: def build(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: with warnings.catch_warnings(): warnings.simplefilter("ignore", SparseEfficiencyWarning) - Adj, Dst = self._build_graph(coords) + Adj, Dst = self.build_graph(coords) - self._apply_filters(Adj, Dst) + self.apply_filters(Adj, Dst) self._apply_percentile(Adj, Dst) Adj.eliminate_zeros() Dst.eliminate_zeros() @@ -63,10 +63,10 @@ def build(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: return self._apply_transform(Adj), Dst @abstractmethod - def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: + def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: """Construct raw adjacency and distance matrices.""" - def _apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: + def apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: """Apply builder-specific post-processing filters.""" return None @@ -105,7 +105,7 @@ def __init__( def coord_type(self) -> CoordType: return CoordType.GENERIC - def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: + def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: N = coords.shape[0] tree = NearestNeighbors(n_neighbors=self.n_neighs, radius=1, metric="euclidean") tree.fit(coords) @@ -145,7 +145,7 @@ def __init__( def coord_type(self) -> CoordType: return CoordType.GENERIC - def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: + def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: N = coords.shape[0] r = self.radius if isinstance(self.radius, int | float) else max(self.radius) tree = NearestNeighbors(n_neighbors=self.n_neighs, radius=r, metric="euclidean") @@ -166,7 +166,7 @@ def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: Dst.setdiag(0.0) return Adj, Dst - def _apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: + def apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: if isinstance(self.radius, Iterable): _filter_by_radius_interval(Adj, Dst, self.radius) @@ -200,7 +200,7 @@ def __init__( def coord_type(self) -> CoordType: return CoordType.GENERIC - def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: + def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: N = coords.shape[0] tri = Delaunay(coords) indptr, indices = tri.vertex_neighbor_vertices @@ -219,7 +219,7 @@ def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: Dst.setdiag(0.0) return Adj, Dst - def _apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: + def apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: if isinstance(self.radius, Iterable): _filter_by_radius_interval(Adj, Dst, self.radius) @@ -246,7 +246,7 @@ def __init__( def coord_type(self) -> CoordType: return CoordType.GRID - def _build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: + def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: if self.n_rings > 1: Adj = self._base_adjacency(coords, set_diag=True) Res, Walk = Adj, Adj diff --git a/tests/graph/test_spatial_neighbors.py b/tests/graph/test_spatial_neighbors.py index 1f6bdbd03..39be507cb 100644 --- a/tests/graph/test_spatial_neighbors.py +++ b/tests/graph/test_spatial_neighbors.py @@ -304,7 +304,7 @@ def test_percentile_filtering(self, adata_hne: AnnData, percentile: float, coord assert not ((result.connectivities != result_filtered.connectivities).nnz == 0) assert result.distances.max() > result_filtered.distances.max() - Adj, Dst = KNNBuilder(n_neighs=6, set_diag=False)._build_graph(adata_hne.obsm["spatial"]) + Adj, Dst = KNNBuilder(n_neighs=6, set_diag=False).build_graph(adata_hne.obsm["spatial"]) threshold = np.percentile(Dst.data, percentile) Adj[Dst > threshold] = 0.0 Dst[Dst > threshold] = 0.0 From 65fa2453a5edb7ca9bb7fdaf1a3775687f7c3205 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 2 Apr 2026 18:00:38 +0200 Subject: [PATCH 23/54] give a better example --- docs/extensibility.md | 50 +++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/extensibility.md b/docs/extensibility.md index 1ea9b0704..00bf73478 100644 --- a/docs/extensibility.md +++ b/docs/extensibility.md @@ -17,28 +17,29 @@ You can implement your own strategy by subclassing it. The base class handles percentile filtering, adjacency transforms, and {class}`~scipy.sparse.SparseEfficiencyWarning` suppression automatically. -### Example: mutual KNN +### Example: fast radius search with SNN -The built-in {class}`~squidpy.gr.neighbors.KNNBuilder` keeps all k-nearest -edges regardless of reciprocity. A mutual KNN graph retains an edge only when -both endpoints consider each other a neighbor, producing a sparser and more -conservative graph: +The built-in {class}`~squidpy.gr.neighbors.RadiusBuilder` uses scikit-learn's +``NearestNeighbors``. The [snnpy](https://github.com/nla-group/snn) library +provides a faster exact fixed-radius search based on PCA-based pruning. The +example below swaps the backend while keeping full compatibility with the rest +of the Squidpy graph pipeline: ```python import numpy as np from scipy.sparse import csr_matrix -from sklearn.neighbors import NearestNeighbors +from snnpy import build_snn_model from squidpy._constants._constants import CoordType from squidpy.gr.neighbors import GraphBuilder -class MutualKNNBuilder(GraphBuilder): - """KNN graph keeping only mutual (reciprocal) edges.""" +class SNNRadiusBuilder(GraphBuilder): + """Radius graph using the SNN fixed-radius search backend.""" - def __init__(self, n_neighs: int = 6, **kwargs): + def __init__(self, radius: float, **kwargs): super().__init__(**kwargs) - self.n_neighs = n_neighs + self.radius = radius @property def coord_type(self) -> CoordType: @@ -46,22 +47,21 @@ class MutualKNNBuilder(GraphBuilder): def build_graph(self, coords): N = coords.shape[0] - tree = NearestNeighbors(n_neighbors=self.n_neighs, metric="euclidean") - tree.fit(coords) - dists, indices = tree.kneighbors() - - row = np.repeat(np.arange(N), self.n_neighs) - col = indices.reshape(-1) - d = dists.reshape(-1) - - Adj = csr_matrix((np.ones_like(d, dtype=np.float32), (row, col)), shape=(N, N)) + model = build_snn_model(coords, verbose=0) + indices, dists = model.batch_query_radius( + coords, self.radius, return_distance=True, + ) + + row = np.repeat(np.arange(N), [len(idx) for idx in indices]) + col = np.concatenate(indices) + d = np.concatenate(dists).astype(np.float64) + + Adj = csr_matrix( + (np.ones(len(row), dtype=np.float32), (row, col)), + shape=(N, N), + ) Dst = csr_matrix((d, (row, col)), shape=(N, N)) - # keep only mutual edges - mutual = Adj.multiply(Adj.T) - Adj = mutual.tocsr() - Dst = Dst.multiply(mutual).tocsr() - Adj.setdiag(1.0 if self.set_diag else Adj.diagonal()) Dst.setdiag(0.0) return Adj, Dst @@ -72,5 +72,5 @@ Use it like any other builder: ```python import squidpy as sq -sq.gr.spatial_neighbors(adata, builder=MutualKNNBuilder(n_neighs=6)) +sq.gr.spatial_neighbors(adata, builder=SNNRadiusBuilder(radius=100.0)) ``` From 85bfdebd98a5a65b551f7cb88234ca59fe07f1ce Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 9 Apr 2026 10:59:30 +0200 Subject: [PATCH 24/54] apply signature suggestion --- src/squidpy/gr/_build.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 203ecc0d7..5ef948ef9 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -447,6 +447,7 @@ def _spatial_neighbors( @d.dedent def spatial_neighbors_knn( adata: AnnData | SpatialData, + *, spatial_key: str = Key.obsm.spatial, elements_to_coordinate_systems: dict[str, str] | None = None, table_key: str | None = None, @@ -497,6 +498,7 @@ def spatial_neighbors_knn( @d.dedent def spatial_neighbors_radius( adata: AnnData | SpatialData, + *, spatial_key: str = Key.obsm.spatial, elements_to_coordinate_systems: dict[str, str] | None = None, table_key: str | None = None, @@ -553,6 +555,7 @@ def spatial_neighbors_radius( @d.dedent def spatial_neighbors_delaunay( adata: AnnData | SpatialData, + *, spatial_key: str = Key.obsm.spatial, elements_to_coordinate_systems: dict[str, str] | None = None, table_key: str | None = None, @@ -604,6 +607,7 @@ def spatial_neighbors_delaunay( @d.dedent def spatial_neighbors_grid( adata: AnnData | SpatialData, + *, spatial_key: str = Key.obsm.spatial, elements_to_coordinate_systems: dict[str, str] | None = None, table_key: str | None = None, From 79f584f82db52da62cdf264bae03c56cbed9f96b Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 9 Apr 2026 11:49:08 +0200 Subject: [PATCH 25/54] reorganize abstractions --- src/squidpy/gr/_build.py | 76 +++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 29 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 5ef948ef9..bfc9aeadd 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -3,7 +3,6 @@ from __future__ import annotations import warnings -from functools import partial from typing import Any, NamedTuple, cast import geopandas as gpd @@ -377,6 +376,26 @@ def spatial_neighbors( ) +def _prepare_spatial_neighbors_input( + adata: AnnData | SpatialData, + *, + spatial_key: str, + elements_to_coordinate_systems: dict[str, str] | None, + table_key: str | None, + library_key: str | None, +) -> tuple[AnnData, str | None]: + """Resolve input data and validate the requested spatial basis.""" + adata, library_key = _resolve_spatial_data( + adata, + spatial_key=spatial_key, + elements_to_coordinate_systems=elements_to_coordinate_systems, + table_key=table_key, + library_key=library_key, + ) + _assert_spatial_basis(adata, spatial_key) + return adata, library_key + + def _spatial_neighbors( adata: AnnData | SpatialData, *, @@ -397,7 +416,7 @@ def _spatial_neighbors( copy: bool = False, ) -> SpatialNeighborsResult | None: """Internal implementation of spatial_neighbors (no deprecation warning).""" - adata, library_key = _resolve_spatial_data( + adata, library_key = _prepare_spatial_neighbors_input( adata, spatial_key=spatial_key, elements_to_coordinate_systems=elements_to_coordinate_systems, @@ -405,8 +424,6 @@ def _spatial_neighbors( library_key=library_key, ) - _assert_spatial_basis(adata, spatial_key) - if builder is not None: _validate_no_legacy_params( coord_type=coord_type, @@ -483,13 +500,18 @@ def spatial_neighbors_knn( transform=transform_enum, set_diag=set_diag, ) - return _spatial_neighbors( + adata, library_key = _prepare_spatial_neighbors_input( adata, spatial_key=spatial_key, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, library_key=library_key, + ) + return _run_spatial_neighbors( + adata, builder=builder, + spatial_key=spatial_key, + library_key=library_key, key_added=key_added, copy=copy, ) @@ -540,13 +562,18 @@ def spatial_neighbors_radius( transform=transform_enum, set_diag=set_diag, ) - return _spatial_neighbors( + adata, library_key = _prepare_spatial_neighbors_input( adata, spatial_key=spatial_key, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, library_key=library_key, + ) + return _run_spatial_neighbors( + adata, builder=builder, + spatial_key=spatial_key, + library_key=library_key, key_added=key_added, copy=copy, ) @@ -592,13 +619,18 @@ def spatial_neighbors_delaunay( transform=transform_enum, set_diag=set_diag, ) - return _spatial_neighbors( + adata, library_key = _prepare_spatial_neighbors_input( adata, spatial_key=spatial_key, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, library_key=library_key, + ) + return _run_spatial_neighbors( + adata, builder=builder, + spatial_key=spatial_key, + library_key=library_key, key_added=key_added, copy=copy, ) @@ -653,13 +685,18 @@ def spatial_neighbors_grid( transform=transform_enum, set_diag=set_diag, ) - return _spatial_neighbors( + adata, library_key = _prepare_spatial_neighbors_input( adata, spatial_key=spatial_key, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, library_key=library_key, + ) + return _run_spatial_neighbors( + adata, builder=builder, + spatial_key=spatial_key, + library_key=library_key, key_added=key_added, copy=copy, ) @@ -685,23 +722,17 @@ def _run_spatial_neighbors( start = logg.info( f"Creating graph using `{builder.coord_type}` coordinates and `{builder.transform}` transform and `{len(libs)}` libraries." ) - _build_fun = partial( - _spatial_neighbor, - spatial_key=spatial_key, - builder=builder, - ) - if library_key is not None: mats: list[tuple[spmatrix, spmatrix]] = [] ixs: list[int] = [] for lib in libs: ixs.extend(np.where(adata.obs[library_key] == lib)[0]) - mats.append(_build_fun(adata[adata.obs[library_key] == lib])) + mats.append(builder.build(adata[adata.obs[library_key] == lib].obsm[spatial_key])) ixs = cast(list[int], np.argsort(ixs).tolist()) Adj = block_diag([m[0] for m in mats], format="csr")[ixs, :][:, ixs] Dst = block_diag([m[1] for m in mats], format="csr")[ixs, :][:, ixs] else: - Adj, Dst = _build_fun(adata) + Adj, Dst = builder.build(adata.obsm[spatial_key]) neighs_key = Key.uns.spatial_neighs(key_added) conns_key = Key.obsp.spatial_conn(key_added) @@ -725,19 +756,6 @@ def _run_spatial_neighbors( _save_data(adata, attr="obsp", key=dists_key, data=Dst, prefix=False) _save_data(adata, attr="uns", key=neighs_key, data=neighbors_dict, prefix=False, time=start) - -def _spatial_neighbor( - adata: AnnData, - spatial_key: str = Key.obsm.spatial, - builder: GraphBuilder | None = None, -) -> tuple[csr_matrix, csr_matrix]: - if builder is None: - raise ValueError("No graph builder was provided.") - - coords = adata.obsm[spatial_key] - return builder.build(coords) - - @d.dedent def mask_graph( sdata: SpatialData, From 43cdbb64c280a40e6e32045a54d3e29d4647de0c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:49:32 +0000 Subject: [PATCH 26/54] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/squidpy/gr/_build.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index bfc9aeadd..29b52269b 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -756,6 +756,7 @@ def _run_spatial_neighbors( _save_data(adata, attr="obsp", key=dists_key, data=Dst, prefix=False) _save_data(adata, attr="uns", key=neighs_key, data=neighbors_dict, prefix=False, time=start) + @d.dedent def mask_graph( sdata: SpatialData, From e632f74d7dc2bf6a808b1886d8c74124386cb995 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 9 Apr 2026 11:53:09 +0200 Subject: [PATCH 27/54] remove unnecessary abstraction --- src/squidpy/gr/_build.py | 80 ++++++++++------------------------------ 1 file changed, 20 insertions(+), 60 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index bfc9aeadd..20eaf16b9 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -356,66 +356,6 @@ def spatial_neighbors( FutureWarning, stacklevel=2, ) - return _spatial_neighbors( - adata, - spatial_key=spatial_key, - elements_to_coordinate_systems=elements_to_coordinate_systems, - table_key=table_key, - library_key=library_key, - coord_type=coord_type, - n_neighs=n_neighs, - radius=radius, - delaunay=delaunay, - n_rings=n_rings, - percentile=percentile, - transform=transform, - set_diag=set_diag, - builder=builder, - key_added=key_added, - copy=copy, - ) - - -def _prepare_spatial_neighbors_input( - adata: AnnData | SpatialData, - *, - spatial_key: str, - elements_to_coordinate_systems: dict[str, str] | None, - table_key: str | None, - library_key: str | None, -) -> tuple[AnnData, str | None]: - """Resolve input data and validate the requested spatial basis.""" - adata, library_key = _resolve_spatial_data( - adata, - spatial_key=spatial_key, - elements_to_coordinate_systems=elements_to_coordinate_systems, - table_key=table_key, - library_key=library_key, - ) - _assert_spatial_basis(adata, spatial_key) - return adata, library_key - - -def _spatial_neighbors( - adata: AnnData | SpatialData, - *, - spatial_key: str = Key.obsm.spatial, - elements_to_coordinate_systems: dict[str, str] | None = None, - table_key: str | None = None, - library_key: str | None = None, - coord_type: str | CoordType | None = None, - n_neighs: int | None = None, - radius: float | tuple[float, float] | None = None, - delaunay: bool | None = None, - n_rings: int | None = None, - percentile: float | None = None, - transform: str | Transform | None = None, - set_diag: bool | None = None, - builder: GraphBuilder | None = None, - key_added: str = "spatial", - copy: bool = False, -) -> SpatialNeighborsResult | None: - """Internal implementation of spatial_neighbors (no deprecation warning).""" adata, library_key = _prepare_spatial_neighbors_input( adata, spatial_key=spatial_key, @@ -461,6 +401,26 @@ def _spatial_neighbors( ) +def _prepare_spatial_neighbors_input( + adata: AnnData | SpatialData, + *, + spatial_key: str, + elements_to_coordinate_systems: dict[str, str] | None, + table_key: str | None, + library_key: str | None, +) -> tuple[AnnData, str | None]: + """Resolve input data and validate the requested spatial basis.""" + adata, library_key = _resolve_spatial_data( + adata, + spatial_key=spatial_key, + elements_to_coordinate_systems=elements_to_coordinate_systems, + table_key=table_key, + library_key=library_key, + ) + _assert_spatial_basis(adata, spatial_key) + return adata, library_key + + @d.dedent def spatial_neighbors_knn( adata: AnnData | SpatialData, From c02686089318fcc79d2a8ba8e623b7993871e586 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 9 Apr 2026 13:04:18 +0200 Subject: [PATCH 28/54] put the warning in resolve graph builder code --- src/squidpy/gr/_build.py | 44 +++++++++++++++------------ src/squidpy/gr/neighbors.py | 12 -------- tests/graph/test_spatial_neighbors.py | 4 +++ 3 files changed, 28 insertions(+), 32 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index b45a8c4c6..4186b5f51 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -75,15 +75,24 @@ def _validate_no_legacy_params(**kwargs: Any) -> None: def _resolve_graph_builder( *, coord_type: str | CoordType | None, - n_neighs: int, + n_neighs: int | None, radius: float | tuple[float, float] | None, - delaunay: bool, - n_rings: int, + delaunay: bool | None, + n_rings: int | None, percentile: float | None, transform: str | Transform | None, - set_diag: bool, + set_diag: bool | None, has_spatial_uns: bool = False, ) -> GraphBuilder: + n_neighs_was_set = n_neighs is not None + n_neighs = 6 if n_neighs is None else n_neighs + delaunay = False if delaunay is None else delaunay + n_rings = 1 if n_rings is None else n_rings + set_diag = False if set_diag is None else set_diag + + assert_positive(n_rings, name="n_rings") + assert_positive(n_neighs, name="n_neighs") + transform = Transform.NONE if transform is None else Transform(transform) if coord_type is None: if radius is not None: @@ -95,23 +104,26 @@ def _resolve_graph_builder( else: coord_type = CoordType(coord_type) - common: dict[str, Any] = { - "n_neighs": n_neighs, - "transform": transform, - "set_diag": set_diag, - } + common: dict[str, Any] = {"transform": transform, "set_diag": set_diag} if coord_type == CoordType.GRID: if percentile is not None: raise ValueError( "`percentile` is not supported for grid coordinates. It only applies to generic (non-grid) graphs." ) - return GridBuilder(**common, n_rings=n_rings, delaunay=delaunay) + return GridBuilder(n_neighs=n_neighs, **common, n_rings=n_rings, delaunay=delaunay) if delaunay: + # below check should be removed once legacy mode spatial_neighbors is deprecated + if n_neighs_was_set: + warnings.warn( + "Parameter `n_neighs` is ignored when `delaunay=True` and will be removed in squidpy v2.0.0.", + FutureWarning, + stacklevel=3, + ) return DelaunayBuilder(**common, radius=radius, percentile=percentile) if radius is not None: - return RadiusBuilder(**common, radius=radius, percentile=percentile) - return KNNBuilder(**common, percentile=percentile) + return RadiusBuilder(n_neighs=n_neighs, **common, radius=radius, percentile=percentile) + return KNNBuilder(n_neighs=n_neighs, **common, percentile=percentile) def _resolve_spatial_data( @@ -376,14 +388,6 @@ def spatial_neighbors( set_diag=set_diag, ) else: - n_neighs = n_neighs if n_neighs is not None else 6 - delaunay = delaunay if delaunay is not None else False - n_rings = n_rings if n_rings is not None else 1 - set_diag = set_diag if set_diag is not None else False - - assert_positive(n_rings, name="n_rings") - assert_positive(n_neighs, name="n_neighs") - builder = _resolve_graph_builder( coord_type=coord_type, n_neighs=n_neighs, diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index 9a15ef3b8..800f1059a 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -177,24 +177,12 @@ class DelaunayBuilder(GraphBuilder): def __init__( self, radius: float | tuple[float, float] | None = None, - n_neighs: int | None = None, transform: str | Transform | None = None, set_diag: bool = False, percentile: float | None = None, ) -> None: - if n_neighs is not None: - warnings.warn( - "Parameter `n_neighs` of `DelaunayBuilder` is ignored by " - "Delaunay triangulation and will be removed in squidpy v2.0.0.", - FutureWarning, - stacklevel=2, - ) - else: - n_neighs = 6 - assert_positive(n_neighs, name="n_neighs") super().__init__(transform=transform, set_diag=set_diag, percentile=percentile) self.radius = radius - self.n_neighs = n_neighs @property def coord_type(self) -> CoordType: diff --git a/tests/graph/test_spatial_neighbors.py b/tests/graph/test_spatial_neighbors.py index 39be507cb..5317fe79d 100644 --- a/tests/graph/test_spatial_neighbors.py +++ b/tests/graph/test_spatial_neighbors.py @@ -295,6 +295,10 @@ def test_delaunay_mode_ignores_scalar_radius(self, non_visium_adata: AnnData): np.testing.assert_array_equal(default.connectivities.toarray(), ignored.connectivities.toarray()) np.testing.assert_allclose(default.distances.toarray(), ignored.distances.toarray()) + def test_delaunay_mode_warns_on_n_neighs(self, non_visium_adata: AnnData): + with pytest.warns(FutureWarning, match=r"Parameter `n_neighs` is ignored when `delaunay=True`"): + spatial_neighbors(non_visium_adata, coord_type="generic", delaunay=True, n_neighs=3, copy=True) + @pytest.mark.parametrize("percentile", [99.0, 95.0]) def test_percentile_filtering(self, adata_hne: AnnData, percentile: float, coord_type="generic"): result = spatial_neighbors(adata_hne, coord_type=coord_type, copy=True) From 10cc2f0628350ba529cddd4c47d4035b495c37b7 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 9 Apr 2026 13:08:53 +0200 Subject: [PATCH 29/54] remove n_neighs from RadiusBuilder --- src/squidpy/gr/_build.py | 17 ++++++++++------- src/squidpy/gr/neighbors.py | 5 +---- tests/graph/test_spatial_neighbors.py | 6 +++++- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 4186b5f51..ca7d0b241 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -122,7 +122,14 @@ def _resolve_graph_builder( ) return DelaunayBuilder(**common, radius=radius, percentile=percentile) if radius is not None: - return RadiusBuilder(n_neighs=n_neighs, **common, radius=radius, percentile=percentile) + # below check should be removed once legacy mode spatial_neighbors is deprecated + if n_neighs_was_set: + warnings.warn( + "Parameter `n_neighs` is ignored when `radius` is set and will be removed in squidpy v2.0.0.", + FutureWarning, + stacklevel=3, + ) + return RadiusBuilder(**common, radius=radius, percentile=percentile) return KNNBuilder(n_neighs=n_neighs, **common, percentile=percentile) @@ -298,6 +305,7 @@ def spatial_neighbors( - Generic radius mode: ``coord_type='generic'``, ``delaunay=False``, ``radius`` set. Uses ``radius`` and builds a radius-based neighbor graph. + ``n_neighs`` is ignored and passing it is deprecated. If ``radius`` is a tuple, the graph is built with the maximum radius and then pruned to the interval ``[min(radius), max(radius)]``. @@ -331,7 +339,7 @@ def spatial_neighbors( A tuple ``radius`` is only used afterward as a pruning interval. A scalar ``radius`` is ignored. - Otherwise, if ``radius`` is set, radius mode is used. - In this mode ``n_neighs`` does not act as a second cutoff. + In this mode ``n_neighs`` is ignored (deprecated). - Otherwise, k-nearest-neighbor mode is used. Grid-specific behavior @@ -490,7 +498,6 @@ def spatial_neighbors_radius( table_key: str | None = None, library_key: str | None = None, radius: float | tuple[float, float] = 1.0, - n_neighs: int = 6, percentile: float | None = None, transform: str | Transform | None = None, set_diag: bool = False, @@ -508,9 +515,6 @@ def spatial_neighbors_radius( radius Neighborhood radius. If a :class:`tuple`, the graph is built with the maximum radius and then pruned to the interval ``[min(radius), max(radius)]``. - n_neighs - Number of nearest neighbors used internally by the radius graph builder. - Defaults to ``6``. %(graph_common_params)s %(copy)s @@ -520,7 +524,6 @@ def spatial_neighbors_radius( """ transform_enum = Transform.NONE if transform is None else Transform(transform) builder = RadiusBuilder( - n_neighs=n_neighs, radius=radius, percentile=percentile, transform=transform_enum, diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index 800f1059a..175a44b03 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -131,15 +131,12 @@ class RadiusBuilder(GraphBuilder): def __init__( self, radius: float | tuple[float, float], - n_neighs: int = 6, transform: str | Transform | None = None, set_diag: bool = False, percentile: float | None = None, ) -> None: - assert_positive(n_neighs, name="n_neighs") super().__init__(transform=transform, set_diag=set_diag, percentile=percentile) self.radius = radius - self.n_neighs = n_neighs @property def coord_type(self) -> CoordType: @@ -148,7 +145,7 @@ def coord_type(self) -> CoordType: def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: N = coords.shape[0] r = self.radius if isinstance(self.radius, int | float) else max(self.radius) - tree = NearestNeighbors(n_neighbors=self.n_neighs, radius=r, metric="euclidean") + tree = NearestNeighbors(radius=r, metric="euclidean") tree.fit(coords) dists, col_indices = tree.radius_neighbors() diff --git a/tests/graph/test_spatial_neighbors.py b/tests/graph/test_spatial_neighbors.py index 5317fe79d..46d5a4a68 100644 --- a/tests/graph/test_spatial_neighbors.py +++ b/tests/graph/test_spatial_neighbors.py @@ -239,7 +239,7 @@ def test_builder_argument_conflict(self, non_visium_adata: AnnData): spatial_neighbors(non_visium_adata, builder=RadiusBuilder(radius=5.0), delaunay=True) def test_builder_rejects_any_legacy_args(self, non_visium_adata: AnnData): - builder = RadiusBuilder(radius=5.0, n_neighs=3, percentile=95.0, transform="cosine", set_diag=True) + builder = RadiusBuilder(radius=5.0, percentile=95.0, transform="cosine", set_diag=True) with pytest.raises(ValueError, match="must not be set"): spatial_neighbors( @@ -299,6 +299,10 @@ def test_delaunay_mode_warns_on_n_neighs(self, non_visium_adata: AnnData): with pytest.warns(FutureWarning, match=r"Parameter `n_neighs` is ignored when `delaunay=True`"): spatial_neighbors(non_visium_adata, coord_type="generic", delaunay=True, n_neighs=3, copy=True) + def test_radius_mode_warns_on_n_neighs(self, non_visium_adata: AnnData): + with pytest.warns(FutureWarning, match=r"Parameter `n_neighs` is ignored when `radius` is set"): + spatial_neighbors(non_visium_adata, coord_type="generic", radius=5.0, n_neighs=3, copy=True) + @pytest.mark.parametrize("percentile", [99.0, 95.0]) def test_percentile_filtering(self, adata_hne: AnnData, percentile: float, coord_type="generic"): result = spatial_neighbors(adata_hne, coord_type=coord_type, copy=True) From 6477f18d7b2a84d03a2eb2f36980f10b2b48c658 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 9 Apr 2026 13:20:49 +0200 Subject: [PATCH 30/54] definition of Adj, Dst and rename to adj, dst --- docs/extensibility.md | 26 ++++++-- src/squidpy/gr/_build.py | 32 +++++----- src/squidpy/gr/neighbors.py | 124 ++++++++++++++++++------------------ 3 files changed, 97 insertions(+), 85 deletions(-) diff --git a/docs/extensibility.md b/docs/extensibility.md index 00bf73478..5797804b3 100644 --- a/docs/extensibility.md +++ b/docs/extensibility.md @@ -11,12 +11,24 @@ You can implement your own strategy by subclassing it. | Method / property | Required | Purpose | |---|---|---| | {attr}`~squidpy.gr.neighbors.GraphBuilder.coord_type` | yes | Return the {class}`~squidpy._constants._constants.CoordType` this builder supports. | -| {meth}`~squidpy.gr.neighbors.GraphBuilder.build_graph` | yes | Construct and return ``(Adj, Dst)`` as {class}`~scipy.sparse.csr_matrix` pair. | -| {meth}`~squidpy.gr.neighbors.GraphBuilder.apply_filters` | no | Post-processing on the raw ``Adj``/``Dst`` (e.g. radius-interval pruning). Called before percentile filtering and transform. | +| {meth}`~squidpy.gr.neighbors.GraphBuilder.build_graph` | yes | Construct and return ``(adj, dst)`` as {class}`~scipy.sparse.csr_matrix` pair. | +| {meth}`~squidpy.gr.neighbors.GraphBuilder.apply_filters` | no | Post-processing on the raw ``adj``/``dst`` (e.g. radius-interval pruning). Called before percentile filtering and transform. | The base class handles percentile filtering, adjacency transforms, and {class}`~scipy.sparse.SparseEfficiencyWarning` suppression automatically. +Here ``adj`` and ``dst`` are square sparse matrices of shape ``(n_obs, n_obs)`` +with matching sparsity structure: + +- ``adj`` is the connectivity / adjacency matrix. Non-zero entries mark edges in + the graph, and built-in builders typically use ``1.0`` for present edges. +- ``dst`` is the distance matrix for those same edges. For generic graphs this is + usually the Euclidean edge length. For grid builders it may instead encode + graph-distance semantics such as ring number. +- Both should be returned as {class}`~scipy.sparse.csr_matrix`. +- By convention, ``dst`` should have a zero diagonal, and ``adj`` should only + have a non-zero diagonal when ``set_diag=True``. + ### Example: fast radius search with SNN The built-in {class}`~squidpy.gr.neighbors.RadiusBuilder` uses scikit-learn's @@ -56,15 +68,15 @@ class SNNRadiusBuilder(GraphBuilder): col = np.concatenate(indices) d = np.concatenate(dists).astype(np.float64) - Adj = csr_matrix( + adj = csr_matrix( (np.ones(len(row), dtype=np.float32), (row, col)), shape=(N, N), ) - Dst = csr_matrix((d, (row, col)), shape=(N, N)) + dst = csr_matrix((d, (row, col)), shape=(N, N)) - Adj.setdiag(1.0 if self.set_diag else Adj.diagonal()) - Dst.setdiag(0.0) - return Adj, Dst + adj.setdiag(1.0 if self.set_diag else adj.diagonal()) + dst.setdiag(0.0) + return adj, dst ``` Use it like any other builder: diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index ca7d0b241..9f71c548c 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -696,10 +696,10 @@ def _run_spatial_neighbors( ixs.extend(np.where(adata.obs[library_key] == lib)[0]) mats.append(builder.build(adata[adata.obs[library_key] == lib].obsm[spatial_key])) ixs = cast(list[int], np.argsort(ixs).tolist()) - Adj = block_diag([m[0] for m in mats], format="csr")[ixs, :][:, ixs] - Dst = block_diag([m[1] for m in mats], format="csr")[ixs, :][:, ixs] + adj = block_diag([m[0] for m in mats], format="csr")[ixs, :][:, ixs] + dst = block_diag([m[1] for m in mats], format="csr")[ixs, :][:, ixs] else: - Adj, Dst = builder.build(adata.obsm[spatial_key]) + adj, dst = builder.build(adata.obsm[spatial_key]) neighs_key = Key.uns.spatial_neighs(key_added) conns_key = Key.obsp.spatial_conn(key_added) @@ -717,10 +717,10 @@ def _run_spatial_neighbors( } if copy: - return SpatialNeighborsResult(connectivities=Adj, distances=Dst) + return SpatialNeighborsResult(connectivities=adj, distances=dst) - _save_data(adata, attr="obsp", key=conns_key, data=Adj) - _save_data(adata, attr="obsp", key=dists_key, data=Dst, prefix=False) + _save_data(adata, attr="obsp", key=conns_key, data=adj) + _save_data(adata, attr="obsp", key=dists_key, data=dst, prefix=False) _save_data(adata, attr="uns", key=neighs_key, data=neighbors_dict, prefix=False, time=start) @@ -782,11 +782,11 @@ def mask_graph( # get elements table = sdata.tables[table_key] coords = table.obsm[spatial_key] - Adj = table.obsp[conns_key] - Dst = table.obsp[dists_key] + adj = table.obsp[conns_key] + dst = table.obsp[dists_key] # convert edges to lines - lines_coords, idx_out = _get_lines_coords(Adj.indices, Adj.indptr, coords) + lines_coords, idx_out = _get_lines_coords(adj.indices, adj.indptr, coords) lines_coords, idx_out = np.array(lines_coords), np.array(idx_out) lines_df = gpd.GeoDataFrame(geometry=list(map(LineString, lines_coords))) @@ -800,12 +800,12 @@ def mask_graph( filt_idx_out = idx_out[filt_lines] # filter connectivities - Adj[filt_idx_out[:, 0], filt_idx_out[:, 1]] = 0 - Adj.eliminate_zeros() + adj[filt_idx_out[:, 0], filt_idx_out[:, 1]] = 0 + adj.eliminate_zeros() # filter_distances - Dst[filt_idx_out[:, 0], filt_idx_out[:, 1]] = 0 - Dst.eliminate_zeros() + dst[filt_idx_out[:, 0], filt_idx_out[:, 1]] = 0 + dst.eliminate_zeros() mask_conns_key = f"{key_added}_{conns_key}" mask_dists_key = f"{key_added}_{dists_key}" @@ -822,11 +822,11 @@ def mask_graph( } if copy: - return Adj, Dst + return adj, dst # save back to spatialdata - _save_data(table, attr="obsp", key=mask_conns_key, data=Adj) - _save_data(table, attr="obsp", key=mask_dists_key, data=Dst, prefix=False) + _save_data(table, attr="obsp", key=mask_conns_key, data=adj) + _save_data(table, attr="obsp", key=mask_dists_key, data=dst, prefix=False) _save_data(table, attr="uns", key=mask_neighs_key, data=neighbors_dict, prefix=False) diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index 175a44b03..9e55229d9 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -53,36 +53,36 @@ def coord_type(self) -> CoordType: def build(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: with warnings.catch_warnings(): warnings.simplefilter("ignore", SparseEfficiencyWarning) - Adj, Dst = self.build_graph(coords) + adj, dst = self.build_graph(coords) - self.apply_filters(Adj, Dst) - self._apply_percentile(Adj, Dst) - Adj.eliminate_zeros() - Dst.eliminate_zeros() + self.apply_filters(adj, dst) + self._apply_percentile(adj, dst) + adj.eliminate_zeros() + dst.eliminate_zeros() - return self._apply_transform(Adj), Dst + return self._apply_transform(adj), dst @abstractmethod def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: """Construct raw adjacency and distance matrices.""" - def apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: + def apply_filters(self, adj: csr_matrix, dst: csr_matrix) -> None: """Apply builder-specific post-processing filters.""" return None - def _apply_percentile(self, Adj: csr_matrix, Dst: csr_matrix) -> None: + def _apply_percentile(self, adj: csr_matrix, dst: csr_matrix) -> None: if self.percentile is not None and self.coord_type == CoordType.GENERIC: - threshold = np.percentile(Dst.data, self.percentile) - Adj[Dst > threshold] = 0.0 - Dst[Dst > threshold] = 0.0 + threshold = np.percentile(dst.data, self.percentile) + adj[dst > threshold] = 0.0 + dst[dst > threshold] = 0.0 - def _apply_transform(self, Adj: csr_matrix) -> csr_matrix: + def _apply_transform(self, adj: csr_matrix) -> csr_matrix: if self.transform == Transform.SPECTRAL: - return cast(csr_matrix, _transform_a_spectral(Adj)) + return cast(csr_matrix, _transform_a_spectral(adj)) if self.transform == Transform.COSINE: - return cast(csr_matrix, _transform_a_cosine(Adj)) + return cast(csr_matrix, _transform_a_cosine(adj)) if self.transform == Transform.NONE: - return Adj + return adj raise NotImplementedError(f"Transform `{self.transform}` is not yet implemented.") @@ -114,15 +114,15 @@ def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: dists, col_indices = dists.reshape(-1), col_indices.reshape(-1) row_indices = np.repeat(np.arange(N), self.n_neighs) - Adj = csr_matrix( + adj = csr_matrix( (np.ones_like(row_indices, dtype=np.float32), (row_indices, col_indices)), shape=(N, N), ) - Dst = csr_matrix((dists, (row_indices, col_indices)), shape=(N, N)) + dst = csr_matrix((dists, (row_indices, col_indices)), shape=(N, N)) - Adj.setdiag(1.0 if self.set_diag else Adj.diagonal()) - Dst.setdiag(0.0) - return Adj, Dst + adj.setdiag(1.0 if self.set_diag else adj.diagonal()) + dst.setdiag(0.0) + return adj, dst class RadiusBuilder(GraphBuilder): @@ -153,19 +153,19 @@ def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: dists = np.concatenate(dists) col_indices = np.concatenate(col_indices) - Adj = csr_matrix( + adj = csr_matrix( (np.ones_like(row_indices, dtype=np.float32), (row_indices, col_indices)), shape=(N, N), ) - Dst = csr_matrix((dists, (row_indices, col_indices)), shape=(N, N)) + dst = csr_matrix((dists, (row_indices, col_indices)), shape=(N, N)) - Adj.setdiag(1.0 if self.set_diag else Adj.diagonal()) - Dst.setdiag(0.0) - return Adj, Dst + adj.setdiag(1.0 if self.set_diag else adj.diagonal()) + dst.setdiag(0.0) + return adj, dst - def apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: + def apply_filters(self, adj: csr_matrix, dst: csr_matrix) -> None: if isinstance(self.radius, Iterable): - _filter_by_radius_interval(Adj, Dst, self.radius) + _filter_by_radius_interval(adj, dst, self.radius) class DelaunayBuilder(GraphBuilder): @@ -189,7 +189,7 @@ def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: N = coords.shape[0] tri = Delaunay(coords) indptr, indices = tri.vertex_neighbor_vertices - Adj = csr_matrix((np.ones_like(indices, dtype=np.float32), indices, indptr), shape=(N, N)) + adj = csr_matrix((np.ones_like(indices, dtype=np.float32), indices, indptr), shape=(N, N)) # fmt: off dists = np.array(list(chain(*( @@ -198,15 +198,15 @@ def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: if len(indices[indptr[i] : indptr[i + 1]]) )))).squeeze() # fmt: on - Dst = csr_matrix((dists, indices, indptr), shape=(N, N)) + dst = csr_matrix((dists, indices, indptr), shape=(N, N)) - Adj.setdiag(1.0 if self.set_diag else Adj.diagonal()) - Dst.setdiag(0.0) - return Adj, Dst + adj.setdiag(1.0 if self.set_diag else adj.diagonal()) + dst.setdiag(0.0) + return adj, dst - def apply_filters(self, Adj: csr_matrix, Dst: csr_matrix) -> None: + def apply_filters(self, adj: csr_matrix, dst: csr_matrix) -> None: if isinstance(self.radius, Iterable): - _filter_by_radius_interval(Adj, Dst, self.radius) + _filter_by_radius_interval(adj, dst, self.radius) class GridBuilder(GraphBuilder): @@ -233,26 +233,26 @@ def coord_type(self) -> CoordType: def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: if self.n_rings > 1: - Adj = self._base_adjacency(coords, set_diag=True) - Res, Walk = Adj, Adj + adj = self._base_adjacency(coords, set_diag=True) + res, walk = adj, adj for i in range(self.n_rings - 1): - Walk = Walk @ Adj - Walk[Res.nonzero()] = 0.0 - Walk.eliminate_zeros() - Walk.data[:] = i + 2.0 - Res = Res + Walk - Adj = Res - Adj.setdiag(float(self.set_diag)) - Adj.eliminate_zeros() - - Dst = Adj.copy() - Adj.data[:] = 1.0 + walk = walk @ adj + walk[res.nonzero()] = 0.0 + walk.eliminate_zeros() + walk.data[:] = i + 2.0 + res = res + walk + adj = res + adj.setdiag(float(self.set_diag)) + adj.eliminate_zeros() + + dst = adj.copy() + adj.data[:] = 1.0 else: - Adj = self._base_adjacency(coords, set_diag=self.set_diag) - Dst = Adj.copy() + adj = self._base_adjacency(coords, set_diag=self.set_diag) + dst = adj.copy() - Dst.setdiag(0.0) - return Adj, Dst + dst.setdiag(0.0) + return adj, dst def _base_adjacency(self, coords: NDArrayA, *, set_diag: bool) -> csr_matrix: """KNN adjacency with median-distance correction for grid coordinates.""" @@ -260,7 +260,7 @@ def _base_adjacency(self, coords: NDArrayA, *, set_diag: bool) -> csr_matrix: if self.delaunay: tri = Delaunay(coords) indptr, indices = tri.vertex_neighbor_vertices - Adj = csr_matrix((np.ones_like(indices, dtype=np.float32), indices, indptr), shape=(N, N)) + adj = csr_matrix((np.ones_like(indices, dtype=np.float32), indices, indptr), shape=(N, N)) else: tree = NearestNeighbors(n_neighbors=self.n_neighs, radius=1, metric="euclidean") tree.fit(coords) @@ -272,13 +272,13 @@ def _base_adjacency(self, coords: NDArrayA, *, set_diag: bool) -> csr_matrix: mask = dists < dist_cutoff row_indices, col_indices = row_indices[mask], col_indices[mask] - Adj = csr_matrix( + adj = csr_matrix( (np.ones_like(row_indices, dtype=np.float32), (row_indices, col_indices)), shape=(N, N), ) - Adj.setdiag(1.0 if set_diag else Adj.diagonal()) - return Adj + adj.setdiag(1.0 if set_diag else adj.diagonal()) + return adj # --------------------------------------------------------------------------- @@ -287,17 +287,17 @@ def _base_adjacency(self, coords: NDArrayA, *, set_diag: bool) -> csr_matrix: def _filter_by_radius_interval( - Adj: csr_matrix, - Dst: csr_matrix, + adj: csr_matrix, + dst: csr_matrix, radius: Iterable[float], ) -> None: minn, maxx = sorted(radius)[:2] - mask = (Dst.data < minn) | (Dst.data > maxx) - a_diag = Adj.diagonal() + mask = (dst.data < minn) | (dst.data > maxx) + a_diag = adj.diagonal() - Dst.data[mask] = 0.0 - Adj.data[mask] = 0.0 - Adj.setdiag(a_diag) + dst.data[mask] = 0.0 + adj.data[mask] = 0.0 + adj.setdiag(a_diag) @njit From 516b3e024079d8225f9f6297979c32e9230047d3 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Fri, 10 Apr 2026 13:48:31 +0200 Subject: [PATCH 31/54] Generalize the base class for extensibility --- docs/extensibility.md | 34 ++++++++++------ src/squidpy/gr/_build.py | 6 +-- src/squidpy/gr/neighbors.py | 77 +++++++++++++++++++++++++------------ 3 files changed, 78 insertions(+), 39 deletions(-) diff --git a/docs/extensibility.md b/docs/extensibility.md index 5797804b3..5cdec8ba2 100644 --- a/docs/extensibility.md +++ b/docs/extensibility.md @@ -2,20 +2,29 @@ ## Custom graph builders -The `squidpy.gr.neighbors` module exposes a {class}`~squidpy.gr.neighbors.GraphBuilder` -base class that all built-in graph construction strategies inherit from. -You can implement your own strategy by subclassing it. +The `squidpy.gr.neighbors` module exposes two builder base classes: + +- {class}`~squidpy.gr.neighbors.GraphBuilder` is the generic builder pipeline. + Use it when you want to plug in a custom coordinate type or sparse-matrix backend. +- {class}`~squidpy.gr.neighbors.GraphBuilderCSR` is the CSR-specialized builder used + by the built-in graph construction strategies. Use it when your builder returns + {class}`~scipy.sparse.csr_matrix` objects and should reuse the default CSR-specific + percentile filtering, transform handling, and sparse warning suppression. ### What to override -| Method / property | Required | Purpose | +| Base class | Method / property | Required | Purpose | |---|---|---| -| {attr}`~squidpy.gr.neighbors.GraphBuilder.coord_type` | yes | Return the {class}`~squidpy._constants._constants.CoordType` this builder supports. | -| {meth}`~squidpy.gr.neighbors.GraphBuilder.build_graph` | yes | Construct and return ``(adj, dst)`` as {class}`~scipy.sparse.csr_matrix` pair. | -| {meth}`~squidpy.gr.neighbors.GraphBuilder.apply_filters` | no | Post-processing on the raw ``adj``/``dst`` (e.g. radius-interval pruning). Called before percentile filtering and transform. | +| {class}`~squidpy.gr.neighbors.GraphBuilder` | {attr}`~squidpy.gr.neighbors.GraphBuilder.coord_type` | yes | Return the {class}`~squidpy._constants._constants.CoordType` this builder supports. | +| {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.build_graph` | yes | Construct and return ``(adj, dst)`` using the coordinate and matrix types of your custom backend. | +| {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.apply_filters` | no | Post-process the raw ``adj``/``dst`` before percentile filtering and transform. | +| {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.apply_percentile` | no | Override percentile handling when the backend needs custom behavior. | +| {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.apply_transform` | no | Override transform handling when the backend needs custom behavior. | +| {class}`~squidpy.gr.neighbors.GraphBuilderCSR` | {meth}`~squidpy.gr.neighbors.GraphBuilderCSR.build_graph` | yes | Construct and return ``(adj, dst)`` as a {class}`~scipy.sparse.csr_matrix` pair. | -The base class handles percentile filtering, adjacency transforms, and -{class}`~scipy.sparse.SparseEfficiencyWarning` suppression automatically. +The generic builder only defines the pipeline. The CSR-specialized builder adds the +built-in CSR behavior for percentile filtering, adjacency transforms, and +{class}`~scipy.sparse.SparseEfficiencyWarning` suppression. Here ``adj`` and ``dst`` are square sparse matrices of shape ``(n_obs, n_obs)`` with matching sparsity structure: @@ -25,7 +34,8 @@ with matching sparsity structure: - ``dst`` is the distance matrix for those same edges. For generic graphs this is usually the Euclidean edge length. For grid builders it may instead encode graph-distance semantics such as ring number. -- Both should be returned as {class}`~scipy.sparse.csr_matrix`. +- When subclassing {class}`~squidpy.gr.neighbors.GraphBuilderCSR`, both should be + returned as {class}`~scipy.sparse.csr_matrix`. - By convention, ``dst`` should have a zero diagonal, and ``adj`` should only have a non-zero diagonal when ``set_diag=True``. @@ -43,10 +53,10 @@ from scipy.sparse import csr_matrix from snnpy import build_snn_model from squidpy._constants._constants import CoordType -from squidpy.gr.neighbors import GraphBuilder +from squidpy.gr.neighbors import GraphBuilderCSR -class SNNRadiusBuilder(GraphBuilder): +class SNNRadiusBuilder(GraphBuilderCSR): """Radius graph using the SNN fixed-radius search backend.""" def __init__(self, radius: float, **kwargs): diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 9f71c548c..2ce017b10 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -83,7 +83,7 @@ def _resolve_graph_builder( transform: str | Transform | None, set_diag: bool | None, has_spatial_uns: bool = False, -) -> GraphBuilder: +) -> GraphBuilder[Any, Any]: n_neighs_was_set = n_neighs is not None n_neighs = 6 if n_neighs is None else n_neighs delaunay = False if delaunay is None else delaunay @@ -209,7 +209,7 @@ def spatial_neighbors( percentile: float | None = None, transform: str | Transform | None = None, set_diag: bool | None = None, - builder: GraphBuilder | None = None, + builder: GraphBuilder[Any, Any] | None = None, key_added: str = "spatial", copy: bool = False, ) -> SpatialNeighborsResult | None: @@ -672,7 +672,7 @@ def spatial_neighbors_grid( def _run_spatial_neighbors( adata: AnnData, *, - builder: GraphBuilder, + builder: GraphBuilder[Any, Any], spatial_key: str = Key.obsm.spatial, library_key: str | None = None, key_added: str = "spatial", diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index 9e55229d9..283ed4c89 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -9,7 +9,7 @@ from abc import ABC, abstractmethod from collections.abc import Iterable from itertools import chain -from typing import cast +from typing import Generic, TypeVar, cast import numpy as np from fast_array_utils import stats as fau_stats @@ -29,10 +29,14 @@ from squidpy._utils import NDArrayA from squidpy._validators import assert_positive -__all__ = ["GraphBuilder", "KNNBuilder", "RadiusBuilder", "DelaunayBuilder", "GridBuilder"] +__all__ = ["GraphBuilder", "GraphBuilderCSR", "KNNBuilder", "RadiusBuilder", "DelaunayBuilder", "GridBuilder"] -class GraphBuilder(ABC): +CoordT = TypeVar("CoordT") +GraphMatrixT = TypeVar("GraphMatrixT") + + +class GraphBuilder(ABC, Generic[CoordT, GraphMatrixT]): """Base class for spatial graph construction strategies.""" def __init__( @@ -50,44 +54,67 @@ def __init__( def coord_type(self) -> CoordType: """Coordinate system supported by this builder.""" + def build(self, coords: CoordT) -> tuple[GraphMatrixT, GraphMatrixT]: + adj, dst = self.build_graph(coords) + adj, dst = self.apply_filters(adj, dst) + adj, dst = self.apply_percentile(adj, dst) + adj, dst = self.apply_transform(adj, dst) + return adj, dst + + + @abstractmethod + def build_graph(self, coords: CoordT) -> tuple[GraphMatrixT, GraphMatrixT]: + """Construct raw adjacency and distance matrices.""" + + def apply_filters(self, adj: GraphMatrixT, dst: GraphMatrixT) -> tuple[GraphMatrixT, GraphMatrixT]: + """Apply builder-specific post-processing filters.""" + return adj, dst + + def apply_percentile(self, adj: GraphMatrixT, dst: GraphMatrixT) -> tuple[GraphMatrixT, GraphMatrixT]: + return adj, dst + + def apply_transform(self, adj: GraphMatrixT, dst: GraphMatrixT) -> tuple[GraphMatrixT, GraphMatrixT]: + return adj, dst + + +class GraphBuilderCSR(GraphBuilder[NDArrayA, csr_matrix], ABC): + """CSR-based graph construction strategy.""" + def build(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: with warnings.catch_warnings(): warnings.simplefilter("ignore", SparseEfficiencyWarning) - adj, dst = self.build_graph(coords) - - self.apply_filters(adj, dst) - self._apply_percentile(adj, dst) - adj.eliminate_zeros() - dst.eliminate_zeros() - - return self._apply_transform(adj), dst + return super().build(coords) @abstractmethod def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: """Construct raw adjacency and distance matrices.""" - def apply_filters(self, adj: csr_matrix, dst: csr_matrix) -> None: + def apply_filters(self, adj: csr_matrix, dst: csr_matrix) -> tuple[csr_matrix, csr_matrix]: """Apply builder-specific post-processing filters.""" - return None + return adj, dst - def _apply_percentile(self, adj: csr_matrix, dst: csr_matrix) -> None: + def apply_percentile(self, adj: csr_matrix, dst: csr_matrix) -> tuple[csr_matrix, csr_matrix]: if self.percentile is not None and self.coord_type == CoordType.GENERIC: threshold = np.percentile(dst.data, self.percentile) adj[dst > threshold] = 0.0 dst[dst > threshold] = 0.0 + return adj, dst + + def apply_transform(self, adj: csr_matrix, dst: csr_matrix) -> tuple[csr_matrix, csr_matrix]: + adj.eliminate_zeros() + dst.eliminate_zeros() - def _apply_transform(self, adj: csr_matrix) -> csr_matrix: if self.transform == Transform.SPECTRAL: - return cast(csr_matrix, _transform_a_spectral(adj)) + return cast(csr_matrix, _transform_a_spectral(adj)), dst if self.transform == Transform.COSINE: - return cast(csr_matrix, _transform_a_cosine(adj)) + return cast(csr_matrix, _transform_a_cosine(adj)), dst if self.transform == Transform.NONE: - return adj + return adj, dst raise NotImplementedError(f"Transform `{self.transform}` is not yet implemented.") -class KNNBuilder(GraphBuilder): +class KNNBuilder(GraphBuilderCSR): """Build a generic k-nearest-neighbor spatial graph.""" def __init__( @@ -125,7 +152,7 @@ def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: return adj, dst -class RadiusBuilder(GraphBuilder): +class RadiusBuilder(GraphBuilderCSR): """Build a generic radius-based spatial graph.""" def __init__( @@ -163,12 +190,13 @@ def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: dst.setdiag(0.0) return adj, dst - def apply_filters(self, adj: csr_matrix, dst: csr_matrix) -> None: + def apply_filters(self, adj: csr_matrix, dst: csr_matrix) -> tuple[csr_matrix, csr_matrix]: if isinstance(self.radius, Iterable): _filter_by_radius_interval(adj, dst, self.radius) + return adj, dst -class DelaunayBuilder(GraphBuilder): +class DelaunayBuilder(GraphBuilderCSR): """Build a generic spatial graph from a Delaunay triangulation.""" def __init__( @@ -204,12 +232,13 @@ def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: dst.setdiag(0.0) return adj, dst - def apply_filters(self, adj: csr_matrix, dst: csr_matrix) -> None: + def apply_filters(self, adj: csr_matrix, dst: csr_matrix) -> tuple[csr_matrix, csr_matrix]: if isinstance(self.radius, Iterable): _filter_by_radius_interval(adj, dst, self.radius) + return adj, dst -class GridBuilder(GraphBuilder): +class GridBuilder(GraphBuilderCSR): """Build a grid-based spatial graph.""" def __init__( From 141fdb690562e84cf7cfeeaacbe59939be2bd610 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:48:44 +0000 Subject: [PATCH 32/54] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/squidpy/gr/neighbors.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index 283ed4c89..eadac65d2 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -61,7 +61,6 @@ def build(self, coords: CoordT) -> tuple[GraphMatrixT, GraphMatrixT]: adj, dst = self.apply_transform(adj, dst) return adj, dst - @abstractmethod def build_graph(self, coords: CoordT) -> tuple[GraphMatrixT, GraphMatrixT]: """Construct raw adjacency and distance matrices.""" From 1fe316beb12ff04bff4e9127223f24be8db6eed3 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Fri, 10 Apr 2026 13:50:55 +0200 Subject: [PATCH 33/54] update docs --- docs/api.md | 1 + docs/extensibility.md | 2 +- src/squidpy/gr/_build.py | 3 +++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/api.md b/docs/api.md index 60434e4be..bf2d39f4e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -126,6 +126,7 @@ See the {doc}`extensibility guide ` for how to implement a custo :toctree: api gr.neighbors.GraphBuilder + gr.neighbors.GraphBuilderCSR gr.neighbors.KNNBuilder gr.neighbors.RadiusBuilder gr.neighbors.DelaunayBuilder diff --git a/docs/extensibility.md b/docs/extensibility.md index 5cdec8ba2..f92e741ec 100644 --- a/docs/extensibility.md +++ b/docs/extensibility.md @@ -14,7 +14,7 @@ The `squidpy.gr.neighbors` module exposes two builder base classes: ### What to override | Base class | Method / property | Required | Purpose | -|---|---|---| +|---|---|---|---| | {class}`~squidpy.gr.neighbors.GraphBuilder` | {attr}`~squidpy.gr.neighbors.GraphBuilder.coord_type` | yes | Return the {class}`~squidpy._constants._constants.CoordType` this builder supports. | | {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.build_graph` | yes | Construct and return ``(adj, dst)`` using the coordinate and matrix types of your custom backend. | | {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.apply_filters` | no | Post-process the raw ``adj``/``dst`` before percentile filtering and transform. | diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 2ce017b10..a843dfb96 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -286,6 +286,9 @@ def spatial_neighbors( Advanced graph construction strategy. When provided, all other graph-construction arguments (``coord_type``, ``n_neighs``, ``radius``, ``delaunay``, ``n_rings``, ``percentile``, ``transform``, ``set_diag``) must be left as ``None``. + Built-in builders subclass {class}`~squidpy.gr.neighbors.GraphBuilderCSR`, + while custom backends can implement the more generic + {class}`~squidpy.gr.neighbors.GraphBuilder` interface directly. key_added Key which controls where the results are saved if ``copy = False``. %(copy)s From bb1ec50c13ed9b67a889ad2843ea0db853b6d748 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Fri, 10 Apr 2026 13:55:32 +0200 Subject: [PATCH 34/54] clarify delauney vs grid --- src/squidpy/gr/_build.py | 5 ++++- src/squidpy/gr/neighbors.py | 13 +++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index a843dfb96..943a657cd 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -637,7 +637,10 @@ def spatial_neighbors_grid( n_rings Number of rings of neighbors. Defaults to ``1``. delaunay - Whether to compute the grid graph from Delaunay triangulation. + Whether to derive the base grid connectivity from a Delaunay triangulation. + This is still grid mode: unlike :func:`spatial_neighbors_delaunay`, the + resulting distance matrix encodes grid or ring distances rather than + Euclidean edge lengths. %(graph_common_params)s %(copy)s diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index eadac65d2..83a86feb9 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -196,7 +196,11 @@ def apply_filters(self, adj: csr_matrix, dst: csr_matrix) -> tuple[csr_matrix, c class DelaunayBuilder(GraphBuilderCSR): - """Build a generic spatial graph from a Delaunay triangulation.""" + """Build a generic point-cloud graph from a Delaunay triangulation. + + Unlike ``GridBuilder(delaunay=True)``, this builder uses the triangulation as the + graph itself and stores real Euclidean edge lengths in ``dst``. + """ def __init__( self, @@ -238,7 +242,12 @@ def apply_filters(self, adj: csr_matrix, dst: csr_matrix) -> tuple[csr_matrix, c class GridBuilder(GraphBuilderCSR): - """Build a grid-based spatial graph.""" + """Build a grid-based spatial graph. + + When ``delaunay=True``, Delaunay triangulation is used only to derive the base + grid connectivity. The resulting ``dst`` still encodes grid or ring distances, + not geometric Euclidean edge lengths. + """ def __init__( self, From 84cbdc4becc1e54b23df4e2c936fb74b87002a31 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Fri, 10 Apr 2026 14:06:29 +0200 Subject: [PATCH 35/54] dtype rec --- docs/extensibility.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/extensibility.md b/docs/extensibility.md index f92e741ec..bfcb41ee6 100644 --- a/docs/extensibility.md +++ b/docs/extensibility.md @@ -36,6 +36,12 @@ with matching sparsity structure: graph-distance semantics such as ring number. - When subclassing {class}`~squidpy.gr.neighbors.GraphBuilderCSR`, both should be returned as {class}`~scipy.sparse.csr_matrix`. +- For CSR-based builders, ``adj`` often behaves like a boolean or indicator + matrix describing whether an edge is present, even if it is stored with a + numeric dtype such as ``float32``. ``dst`` stores edge-associated values such + as distances and will often use a floating-point dtype. The exact dtype choice + is left to the builder implementation and may depend on performance, memory, + and numerical accuracy requirements. - By convention, ``dst`` should have a zero diagonal, and ``adj`` should only have a non-zero diagonal when ``set_diag=True``. From 6d942ec8dbef664a3e986d53876d6b5a621a0150 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Fri, 10 Apr 2026 14:12:42 +0200 Subject: [PATCH 36/54] be a bit more verbose on funcitons --- src/squidpy/gr/_build.py | 47 ++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 943a657cd..ccf83655c 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -286,9 +286,9 @@ def spatial_neighbors( Advanced graph construction strategy. When provided, all other graph-construction arguments (``coord_type``, ``n_neighs``, ``radius``, ``delaunay``, ``n_rings``, ``percentile``, ``transform``, ``set_diag``) must be left as ``None``. - Built-in builders subclass {class}`~squidpy.gr.neighbors.GraphBuilderCSR`, + Built-in builders subclass {{class}}`~squidpy.gr.neighbors.GraphBuilderCSR`, while custom backends can implement the more generic - {class}`~squidpy.gr.neighbors.GraphBuilder` interface directly. + {{class}}`~squidpy.gr.neighbors.GraphBuilder` interface directly. key_added Key which controls where the results are saved if ``copy = False``. %(copy)s @@ -453,6 +453,10 @@ def spatial_neighbors_knn( ) -> SpatialNeighborsResult | None: """Create a k-nearest-neighbor graph from spatial coordinates. + Each observation is connected to its ``n_neighs`` nearest observations in + Euclidean space. This mode is typically most useful for continuous + coordinates, where you want to control neighborhood size directly. + Parameters ---------- %(adata)s @@ -460,7 +464,8 @@ def spatial_neighbors_knn( %(sdata_params)s %(library_key)s n_neighs - Number of nearest neighbors. Defaults to ``6``. + Number of nearest neighbors. Defaults to ``6``. Smaller values produce a + sparser, more local graph; larger values connect broader neighborhoods. %(graph_common_params)s %(copy)s @@ -509,6 +514,10 @@ def spatial_neighbors_radius( ) -> SpatialNeighborsResult | None: """Create a radius-based graph from spatial coordinates. + Two observations are connected when their Euclidean distance falls within the + requested radius. This mode is useful when a physical interaction scale is + more meaningful than a fixed number of neighbors. + Parameters ---------- %(adata)s @@ -518,6 +527,9 @@ def spatial_neighbors_radius( radius Neighborhood radius. If a :class:`tuple`, the graph is built with the maximum radius and then pruned to the interval ``[min(radius), max(radius)]``. + In practice, a single value defines a disk around each observation, + whereas a tuple defines an annulus by keeping only edges within the + specified distance interval. %(graph_common_params)s %(copy)s @@ -566,6 +578,11 @@ def spatial_neighbors_delaunay( ) -> SpatialNeighborsResult | None: """Create a Delaunay triangulation graph from spatial coordinates. + Delaunay triangulation connects observations into triangles such that no + other observation lies inside the circumcircle of each triangle. In + practice, this yields an adaptive geometry-driven graph rather than one + based on a fixed ``k`` or radius, and ``dst`` stores Euclidean edge lengths. + Parameters ---------- %(adata)s @@ -574,7 +591,9 @@ def spatial_neighbors_delaunay( %(library_key)s radius If a :class:`tuple`, used as a post-construction pruning interval - ``[min(radius), max(radius)]``. + ``[min(radius), max(radius)]``. This does not change the triangulation + itself; it only removes Delaunay edges whose Euclidean lengths fall + outside the interval. %(graph_common_params)s %(copy)s @@ -625,6 +644,10 @@ def spatial_neighbors_grid( """Create a grid-based graph from spatial coordinates. This is the mode used for Visium-like grid coordinates. + It assumes observations lie on an approximately regular lattice, so it is + usually not appropriate for continuous coordinates such as Xenium point + clouds. On irregular coordinates, the resulting graph and ring distances may + not have a meaningful grid interpretation. Parameters ---------- @@ -633,14 +656,24 @@ def spatial_neighbors_grid( %(sdata_params)s %(library_key)s n_neighs - Number of neighboring tiles. Defaults to ``6``. + Number of neighboring tiles used to form the base grid connectivity. + Defaults to ``6``. On a Visium-like hexagonal grid, ``6`` corresponds to + the immediate surrounding spots, while smaller values such as ``3`` make + the first-ring graph deliberately sparser. n_rings - Number of rings of neighbors. Defaults to ``1``. + Number of rings of neighbors. Defaults to ``1``. ``n_rings=1`` keeps + only immediate neighbors; larger values add progressively more distant + shells and encode the shell number in ``dst``. For example, + ``n_neighs=3`` with ``n_rings=2`` on a Visium-like grid starts from a + sparse three-neighbor base graph and then adds a second graph-distance + ring relative to that base connectivity. delaunay Whether to derive the base grid connectivity from a Delaunay triangulation. This is still grid mode: unlike :func:`spatial_neighbors_delaunay`, the resulting distance matrix encodes grid or ring distances rather than - Euclidean edge lengths. + Euclidean edge lengths. In practice, this changes how the first-ring + connectivity is inferred, but not the meaning of the resulting + distances. %(graph_common_params)s %(copy)s From ae9a4e6727d1245ac6cea68c89671b6f86e7aa0a Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Fri, 10 Apr 2026 14:41:24 +0200 Subject: [PATCH 37/54] spatial_neighbours_from_builder --- docs/api.md | 1 + docs/extensibility.md | 4 +- src/squidpy/gr/__init__.py | 2 + src/squidpy/gr/_build.py | 149 ++++++++++++++------------ tests/graph/test_spatial_neighbors.py | 43 ++------ 5 files changed, 92 insertions(+), 107 deletions(-) diff --git a/docs/api.md b/docs/api.md index bf2d39f4e..dd19bb18e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -18,6 +18,7 @@ import squidpy as sq :toctree: api gr.spatial_neighbors + gr.spatial_neighbors_from_builder gr.spatial_neighbors_knn gr.spatial_neighbors_radius gr.spatial_neighbors_delaunay diff --git a/docs/extensibility.md b/docs/extensibility.md index bfcb41ee6..aa2845bf5 100644 --- a/docs/extensibility.md +++ b/docs/extensibility.md @@ -20,7 +20,7 @@ The `squidpy.gr.neighbors` module exposes two builder base classes: | {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.apply_filters` | no | Post-process the raw ``adj``/``dst`` before percentile filtering and transform. | | {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.apply_percentile` | no | Override percentile handling when the backend needs custom behavior. | | {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.apply_transform` | no | Override transform handling when the backend needs custom behavior. | -| {class}`~squidpy.gr.neighbors.GraphBuilderCSR` | {meth}`~squidpy.gr.neighbors.GraphBuilderCSR.build_graph` | yes | Construct and return ``(adj, dst)`` as a {class}`~scipy.sparse.csr_matrix` pair. | + The generic builder only defines the pipeline. The CSR-specialized builder adds the built-in CSR behavior for percentile filtering, adjacency transforms, and @@ -100,5 +100,5 @@ Use it like any other builder: ```python import squidpy as sq -sq.gr.spatial_neighbors(adata, builder=SNNRadiusBuilder(radius=100.0)) +sq.gr.spatial_neighbors_from_builder(adata, SNNRadiusBuilder(radius=100.0)) ``` diff --git a/src/squidpy/gr/__init__.py b/src/squidpy/gr/__init__.py index a5dd26973..fce615b66 100644 --- a/src/squidpy/gr/__init__.py +++ b/src/squidpy/gr/__init__.py @@ -8,6 +8,7 @@ mask_graph, spatial_neighbors, spatial_neighbors_delaunay, + spatial_neighbors_from_builder, spatial_neighbors_grid, spatial_neighbors_knn, spatial_neighbors_radius, @@ -30,6 +31,7 @@ "neighbors", "mask_graph", "spatial_neighbors", + "spatial_neighbors_from_builder", "spatial_neighbors_knn", "spatial_neighbors_radius", "spatial_neighbors_delaunay", diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index ccf83655c..516dd5ed8 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -49,6 +49,7 @@ __all__ = [ "SpatialNeighborsResult", "spatial_neighbors", + "spatial_neighbors_from_builder", "spatial_neighbors_knn", "spatial_neighbors_radius", "spatial_neighbors_delaunay", @@ -63,15 +64,6 @@ class SpatialNeighborsResult(NamedTuple): distances: csr_matrix -def _validate_no_legacy_params(**kwargs: Any) -> None: - conflicts = [k for k, v in kwargs.items() if v is not None] - if conflicts: - raise ValueError( - "When `builder` is provided, graph-construction arguments must not be set. " - f"Got non-default values for: {', '.join(conflicts)}." - ) - - def _resolve_graph_builder( *, coord_type: str | CoordType | None, @@ -209,7 +201,6 @@ def spatial_neighbors( percentile: float | None = None, transform: str | Transform | None = None, set_diag: bool | None = None, - builder: GraphBuilder[Any, Any] | None = None, key_added: str = "spatial", copy: bool = False, ) -> SpatialNeighborsResult | None: @@ -217,16 +208,14 @@ def spatial_neighbors( Create a graph from spatial coordinates. .. deprecated:: 1.6.0 - The flat-parameter API of ``spatial_neighbors`` is deprecated and will - be removed in squidpy v1.7.0. Use one of the mode-specific functions - instead: + ``spatial_neighbors`` is deprecated and will be removed in squidpy + v1.7.0. Use one of the mode-specific functions instead: - :func:`spatial_neighbors_knn` - :func:`spatial_neighbors_radius` - :func:`spatial_neighbors_delaunay` - :func:`spatial_neighbors_grid` - - Passing a ``builder`` instance directly remains supported. + - :func:`spatial_neighbors_from_builder` Parameters ---------- @@ -243,8 +232,7 @@ def spatial_neighbors( `adata` is a :class:`spatialdata.SpatialData`. %(library_key)s coord_type - Type of coordinate system. Must not be set when ``builder`` is given. - Valid options are: + Type of coordinate system. Valid options are: - `{c.GRID.s!r}` - grid coordinates. - `{c.GENERIC.s!r}` - generic coordinates. @@ -256,24 +244,23 @@ def spatial_neighbors( - `{c.GRID.s!r}` - number of neighboring tiles. - `{c.GENERIC.s!r}` - number of neighborhoods for non-grid data. Only used when ``delaunay = False``. - Defaults to ``6`` when no ``builder`` is provided. Must not be set when ``builder`` is given. + Defaults to ``6``. radius - Only available when ``coord_type = {c.GENERIC.s!r}``. Must not be set when ``builder`` is given. + Only available when ``coord_type = {c.GENERIC.s!r}``. Depending on the type: - :class:`float` - compute the graph based on neighborhood radius. - :class:`tuple` - prune the final graph to only contain edges in interval `[min(radius), max(radius)]`. delaunay Whether to compute the graph from Delaunay triangulation. Only used when ``coord_type = {c.GENERIC.s!r}``. - Defaults to ``False`` when no ``builder`` is provided. Must not be set when ``builder`` is given. + Defaults to ``False``. n_rings Number of rings of neighbors for grid data. Only used when ``coord_type = {c.GRID.s!r}``. - Defaults to ``1`` when no ``builder`` is provided. Must not be set when ``builder`` is given. + Defaults to ``1``. percentile Percentile of the distances to use as threshold. Only used when ``coord_type = {c.GENERIC.s!r}``. - Must not be set when ``builder`` is given. transform - Type of adjacency matrix transform. Must not be set when ``builder`` is given. + Type of adjacency matrix transform. Valid options are: - `{t.SPECTRAL.s!r}` - spectral transformation of the adjacency matrix. @@ -281,14 +268,7 @@ def spatial_neighbors( - `{t.NONE.v}` - no transformation of the adjacency matrix. set_diag Whether to set the diagonal of the spatial connectivities to `1.0`. - Defaults to ``False`` when no ``builder`` is provided. Must not be set when ``builder`` is given. - builder - Advanced graph construction strategy. When provided, all other graph-construction - arguments (``coord_type``, ``n_neighs``, ``radius``, ``delaunay``, ``n_rings``, - ``percentile``, ``transform``, ``set_diag``) must be left as ``None``. - Built-in builders subclass {{class}}`~squidpy.gr.neighbors.GraphBuilderCSR`, - while custom backends can implement the more generic - {{class}}`~squidpy.gr.neighbors.GraphBuilder` interface directly. + Defaults to ``False``. key_added Key which controls where the results are saved if ``copy = False``. %(copy)s @@ -323,9 +303,6 @@ def spatial_neighbors( - ``percentile`` only affects generic graphs. - ``transform`` and ``set_diag`` apply to all modes. - - If ``builder`` is provided, it determines the mode directly. - All other graph-construction arguments must be left as - ``None``. - By default, observations are not treated as their own neighbors. The distance matrix always has a zero diagonal. The connectivity matrix only gets a nonzero diagonal when @@ -333,7 +310,7 @@ def spatial_neighbors( Argument precedence ------------------- - When ``builder`` is not provided, the mode is resolved as follows: + The mode is resolved as follows: - If ``coord_type`` resolves to ``'grid'``, grid mode is used. In that case ``radius`` is ignored. @@ -370,15 +347,14 @@ def spatial_neighbors( - :attr:`anndata.AnnData.obsp` ``['{{key_added}}_distances']`` - the spatial distances. - :attr:`anndata.AnnData.uns` ``['{{key_added}}']`` - :class:`dict` containing parameters. """ - if builder is None: - warnings.warn( - "Calling `spatial_neighbors` without a `builder` argument is deprecated " - "and will be removed in squidpy v1.7.0. Use one of the mode-specific " - "functions instead: `spatial_neighbors_knn`, `spatial_neighbors_radius`, " - "`spatial_neighbors_delaunay`, or `spatial_neighbors_grid`.", - FutureWarning, - stacklevel=2, - ) + warnings.warn( + "Calling `spatial_neighbors` is deprecated and will be removed in squidpy " + "v1.7.0. Use `spatial_neighbors_knn`, `spatial_neighbors_radius`, " + "`spatial_neighbors_delaunay`, `spatial_neighbors_grid`, or " + "`spatial_neighbors_from_builder` instead.", + FutureWarning, + stacklevel=2, + ) adata, library_key = _prepare_spatial_neighbors_input( adata, spatial_key=spatial_key, @@ -386,36 +362,73 @@ def spatial_neighbors( table_key=table_key, library_key=library_key, ) - - if builder is not None: - _validate_no_legacy_params( - coord_type=coord_type, - n_neighs=n_neighs, - radius=radius, - delaunay=delaunay, - n_rings=n_rings, - percentile=percentile, - transform=transform, - set_diag=set_diag, - ) - else: - builder = _resolve_graph_builder( - coord_type=coord_type, - n_neighs=n_neighs, - radius=radius, - delaunay=delaunay, - n_rings=n_rings, - percentile=percentile, - transform=transform, - set_diag=set_diag, - has_spatial_uns=Key.uns.spatial in adata.uns, - ) + builder = _resolve_graph_builder( + coord_type=coord_type, + n_neighs=n_neighs, + radius=radius, + delaunay=delaunay, + n_rings=n_rings, + percentile=percentile, + transform=transform, + set_diag=set_diag, + has_spatial_uns=Key.uns.spatial in adata.uns, + ) return _run_spatial_neighbors( adata, builder=builder, spatial_key=spatial_key, library_key=library_key, key_added=key_added, copy=copy ) +@d.dedent +def spatial_neighbors_from_builder( + adata: AnnData | SpatialData, + builder: GraphBuilder[Any, Any], + *, + spatial_key: str = Key.obsm.spatial, + elements_to_coordinate_systems: dict[str, str] | None = None, + table_key: str | None = None, + library_key: str | None = None, + key_added: str = "spatial", + copy: bool = False, +) -> SpatialNeighborsResult | None: + """Create a graph from spatial coordinates using an explicit builder instance. + + Parameters + ---------- + %(adata)s + builder + Graph construction strategy to execute. Built-in builders subclass + {{class}}`~squidpy.gr.neighbors.GraphBuilderCSR`, while custom backends + can implement the more generic + {{class}}`~squidpy.gr.neighbors.GraphBuilder` interface directly. + %(spatial_key)s + %(sdata_params)s + %(library_key)s + key_added + Key which controls where the results are saved if ``copy = False``. + %(copy)s + + Returns + ------- + %(spatial_neighbors_returns)s + """ + adata, library_key = _prepare_spatial_neighbors_input( + adata, + spatial_key=spatial_key, + elements_to_coordinate_systems=elements_to_coordinate_systems, + table_key=table_key, + library_key=library_key, + ) + return _run_spatial_neighbors( + adata, + builder=builder, + spatial_key=spatial_key, + library_key=library_key, + key_added=key_added, + copy=copy, + ) + + def _prepare_spatial_neighbors_input( adata: AnnData | SpatialData, *, diff --git a/tests/graph/test_spatial_neighbors.py b/tests/graph/test_spatial_neighbors.py index 46d5a4a68..150d211bb 100644 --- a/tests/graph/test_spatial_neighbors.py +++ b/tests/graph/test_spatial_neighbors.py @@ -12,7 +12,7 @@ from squidpy._constants._constants import Transform from squidpy._constants._pkg_constants import Key -from squidpy.gr import mask_graph, spatial_neighbors +from squidpy.gr import mask_graph, spatial_neighbors, spatial_neighbors_from_builder from squidpy.gr.neighbors import ( DelaunayBuilder, GridBuilder, @@ -214,7 +214,7 @@ def test_builder_module_export(self): ) def test_generic_builder_matches_legacy(self, non_visium_adata: AnnData, legacy_kwargs: dict, builder: object): legacy = spatial_neighbors(non_visium_adata, **legacy_kwargs, copy=True) - result = spatial_neighbors(non_visium_adata, builder=builder, copy=True) + result = spatial_neighbors_from_builder(non_visium_adata, builder=builder, copy=True) np.testing.assert_array_equal(legacy.connectivities.toarray(), result.connectivities.toarray()) np.testing.assert_allclose(legacy.distances.toarray(), result.distances.toarray()) @@ -229,51 +229,20 @@ def test_generic_builder_matches_legacy(self, non_visium_adata: AnnData, legacy_ ) def test_grid_builder_matches_legacy(self, adata_squaregrid: AnnData, legacy_kwargs: dict, builder: object): legacy = spatial_neighbors(adata_squaregrid, **legacy_kwargs, copy=True) - result = spatial_neighbors(adata_squaregrid, builder=builder, copy=True) + result = spatial_neighbors_from_builder(adata_squaregrid, builder=builder, copy=True) np.testing.assert_array_equal(legacy.connectivities.toarray(), result.connectivities.toarray()) np.testing.assert_allclose(legacy.distances.toarray(), result.distances.toarray()) - def test_builder_argument_conflict(self, non_visium_adata: AnnData): - with pytest.raises(ValueError, match="must not be set"): - spatial_neighbors(non_visium_adata, builder=RadiusBuilder(radius=5.0), delaunay=True) - - def test_builder_rejects_any_legacy_args(self, non_visium_adata: AnnData): - builder = RadiusBuilder(radius=5.0, percentile=95.0, transform="cosine", set_diag=True) - - with pytest.raises(ValueError, match="must not be set"): - spatial_neighbors( - non_visium_adata, - builder=builder, - coord_type="generic", - n_neighs=3, - radius=5.0, - percentile=95.0, - transform="cosine", - set_diag=True, - copy=True, - ) - - def test_builder_allows_none_legacy_args(self, non_visium_adata: AnnData): + def test_builder_explicit_entry_point(self, non_visium_adata: AnnData): builder = KNNBuilder(n_neighs=3, transform=Transform.NONE) - baseline = spatial_neighbors(non_visium_adata, builder=builder, copy=True) - matched = spatial_neighbors(non_visium_adata, builder=builder, transform=None, copy=True) + baseline = spatial_neighbors_from_builder(non_visium_adata, builder=builder, copy=True) + matched = spatial_neighbors_from_builder(non_visium_adata, builder=builder, copy=True) np.testing.assert_array_equal(baseline.connectivities.toarray(), matched.connectivities.toarray()) np.testing.assert_allclose(baseline.distances.toarray(), matched.distances.toarray()) - def test_builder_rejects_radius(self, non_visium_adata: AnnData): - builder = RadiusBuilder(radius=(4.0, 2.0)) - - with pytest.raises(ValueError, match="must not be set"): - spatial_neighbors( - non_visium_adata, - builder=builder, - radius=(2.0, 4.0), - copy=True, - ) - def test_grid_mode_ignores_radius(self, adata_squaregrid: AnnData): default = spatial_neighbors(adata_squaregrid, coord_type="grid", n_neighs=4, n_rings=2, copy=True) ignored = spatial_neighbors( From cd3f6fd7ea879bc6dbcc0e6ef154f4e016e1bb2c Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Fri, 10 Apr 2026 15:36:35 +0200 Subject: [PATCH 38/54] update docs to refer to each other --- src/squidpy/gr/_build.py | 37 +++++++++++++++++++++++++++ src/squidpy/gr/neighbors.py | 51 +++++++++++++++++++++++++++++++------ 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 516dd5ed8..bf99398e9 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -393,6 +393,14 @@ def spatial_neighbors_from_builder( ) -> SpatialNeighborsResult | None: """Create a graph from spatial coordinates using an explicit builder instance. + This function is the bridge between the high-level API (e.g., + :func:`spatial_neighbors_knn`, :func:`spatial_neighbors_radius`) and advanced + customization via builder classes. Use this when you need to: + + - Stack or chain builder behaviors + - Pass pre-configured builder instances multiple times + - Implement custom builders (see :doc:`/extensibility`) + Parameters ---------- %(adata)s @@ -411,6 +419,14 @@ def spatial_neighbors_from_builder( Returns ------- %(spatial_neighbors_returns)s + + See Also + -------- + spatial_neighbors_knn : k-nearest-neighbor graphs (wraps :class:`~squidpy.gr.neighbors.KNNBuilder`). + spatial_neighbors_radius : radius-based graphs (wraps :class:`~squidpy.gr.neighbors.RadiusBuilder`). + spatial_neighbors_delaunay : Delaunay triangulation graphs (wraps :class:`~squidpy.gr.neighbors.DelaunayBuilder`). + spatial_neighbors_grid : grid-based graphs (wraps :class:`~squidpy.gr.neighbors.GridBuilder`). + gr.neighbors.GraphBuilder : Base builder interface. Inherit from this or :class:`~squidpy.gr.neighbors.GraphBuilderCSR` to implement custom graph construction. """ adata, library_key = _prepare_spatial_neighbors_input( adata, @@ -485,6 +501,11 @@ def spatial_neighbors_knn( Returns ------- %(spatial_neighbors_returns)s + + See Also + -------- + spatial_neighbors_from_builder : Use :class:`~squidpy.gr.neighbors.KNNBuilder` directly for advanced customization. + gr.neighbors.KNNBuilder : k-nearest-neighbor builder class. """ transform_enum = Transform.NONE if transform is None else Transform(transform) builder = KNNBuilder( @@ -549,6 +570,11 @@ def spatial_neighbors_radius( Returns ------- %(spatial_neighbors_returns)s + + See Also + -------- + spatial_neighbors_from_builder : Use :class:`~squidpy.gr.neighbors.RadiusBuilder` directly for advanced customization. + gr.neighbors.RadiusBuilder : radius-based builder class. """ transform_enum = Transform.NONE if transform is None else Transform(transform) builder = RadiusBuilder( @@ -613,6 +639,11 @@ def spatial_neighbors_delaunay( Returns ------- %(spatial_neighbors_returns)s + + See Also + -------- + spatial_neighbors_from_builder : Use :class:`~squidpy.gr.neighbors.DelaunayBuilder` directly for advanced customization. + gr.neighbors.DelaunayBuilder : Delaunay triangulation builder class. """ transform_enum = Transform.NONE if transform is None else Transform(transform) builder = DelaunayBuilder( @@ -693,6 +724,11 @@ def spatial_neighbors_grid( Returns ------- %(spatial_neighbors_returns)s + + See Also + -------- + spatial_neighbors_from_builder : Use :class:`~squidpy.gr.neighbors.GridBuilder` directly for advanced customization. + gr.neighbors.GridBuilder : grid-based builder class. """ assert_positive(n_rings, name="n_rings") assert_positive(n_neighs, name="n_neighs") @@ -774,6 +810,7 @@ def _run_spatial_neighbors( _save_data(adata, attr="obsp", key=conns_key, data=adj) _save_data(adata, attr="obsp", key=dists_key, data=dst, prefix=False) _save_data(adata, attr="uns", key=neighs_key, data=neighbors_dict, prefix=False, time=start) + return None @d.dedent diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index 83a86feb9..2a1e80605 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -77,7 +77,22 @@ def apply_transform(self, adj: GraphMatrixT, dst: GraphMatrixT) -> tuple[GraphMa class GraphBuilderCSR(GraphBuilder[NDArrayA, csr_matrix], ABC): - """CSR-based graph construction strategy.""" + """CSR-based graph construction strategy. + + Specializes :class:`GraphBuilder` for sparse CSR matrix output. Adds built-in handling + for percentile-based edge pruning, adjacency transforms (spectral/cosine), and + SparseEfficiencyWarning suppression. All built-in concrete builders + (:class:`KNNBuilder`, :class:`RadiusBuilder`, :class:`DelaunayBuilder`, :class:`GridBuilder`) + inherit from this class. + + Subclass this (not the generic :class:`GraphBuilder`) when implementing a builder + that returns CSR matrices. + + See Also + -------- + GraphBuilder : Generic builder interface for custom coordinate/matrix types. + KNNBuilder : Example of a concrete CSR-based builder. + """ def build(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: with warnings.catch_warnings(): @@ -114,7 +129,12 @@ def apply_transform(self, adj: csr_matrix, dst: csr_matrix) -> tuple[csr_matrix, class KNNBuilder(GraphBuilderCSR): - """Build a generic k-nearest-neighbor spatial graph.""" + """Build a generic k-nearest-neighbor spatial graph. + + Each observation is connected to its k nearest neighbors. See + :func:`~squidpy.gr.spatial_neighbors_knn` for the user-facing API or + :func:`~squidpy.gr.spatial_neighbors_from_builder` for direct builder usage. + """ def __init__( self, @@ -152,7 +172,13 @@ def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: class RadiusBuilder(GraphBuilderCSR): - """Build a generic radius-based spatial graph.""" + """Build a generic radius-based spatial graph. + + Two observations are connected when their Euclidean distance falls within + the specified radius. See :func:`~squidpy.gr.spatial_neighbors_radius` for the + user-facing API or :func:`~squidpy.gr.spatial_neighbors_from_builder` for + direct builder usage. + """ def __init__( self, @@ -198,8 +224,13 @@ def apply_filters(self, adj: csr_matrix, dst: csr_matrix) -> tuple[csr_matrix, c class DelaunayBuilder(GraphBuilderCSR): """Build a generic point-cloud graph from a Delaunay triangulation. - Unlike ``GridBuilder(delaunay=True)``, this builder uses the triangulation as the - graph itself and stores real Euclidean edge lengths in ``dst``. + Delaunay triangulation connects observations into triangles such that no + other observation lies inside the circumcircle of each triangle. Unlike + ``GridBuilder(delaunay=True)``, this builder uses geometry-based connectivity + and stores real Euclidean edge lengths. + + See :func:`~squidpy.gr.spatial_neighbors_delaunay` for the user-facing API or + :func:`~squidpy.gr.spatial_neighbors_from_builder` for direct builder usage. """ def __init__( @@ -244,9 +275,13 @@ def apply_filters(self, adj: csr_matrix, dst: csr_matrix) -> tuple[csr_matrix, c class GridBuilder(GraphBuilderCSR): """Build a grid-based spatial graph. - When ``delaunay=True``, Delaunay triangulation is used only to derive the base - grid connectivity. The resulting ``dst`` still encodes grid or ring distances, - not geometric Euclidean edge lengths. + Assumes observations lie on an approximately regular lattice (e.g., Visium). + When ``delaunay=True``, Delaunay triangulation is used only to derive the + base connectivity; the distance matrix still encodes grid/ring distances, + not Euclidean lengths. + + See :func:`~squidpy.gr.spatial_neighbors_grid` for the user-facing API or + :func:`~squidpy.gr.spatial_neighbors_from_builder` for direct builder usage. """ def __init__( From 95012dc54ec158044d06fbf8bf54bdec2e53cec5 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Fri, 10 Apr 2026 15:40:23 +0200 Subject: [PATCH 39/54] refer to the classes properly --- src/squidpy/gr/_build.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index bf99398e9..3d1df5c9a 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -426,7 +426,7 @@ def spatial_neighbors_from_builder( spatial_neighbors_radius : radius-based graphs (wraps :class:`~squidpy.gr.neighbors.RadiusBuilder`). spatial_neighbors_delaunay : Delaunay triangulation graphs (wraps :class:`~squidpy.gr.neighbors.DelaunayBuilder`). spatial_neighbors_grid : grid-based graphs (wraps :class:`~squidpy.gr.neighbors.GridBuilder`). - gr.neighbors.GraphBuilder : Base builder interface. Inherit from this or :class:`~squidpy.gr.neighbors.GraphBuilderCSR` to implement custom graph construction. + squidpy.gr.neighbors.GraphBuilder : Base builder interface. Inherit from this or :class:`~squidpy.gr.neighbors.GraphBuilderCSR` to implement custom graph construction. """ adata, library_key = _prepare_spatial_neighbors_input( adata, @@ -505,7 +505,7 @@ def spatial_neighbors_knn( See Also -------- spatial_neighbors_from_builder : Use :class:`~squidpy.gr.neighbors.KNNBuilder` directly for advanced customization. - gr.neighbors.KNNBuilder : k-nearest-neighbor builder class. + squidpy.gr.neighbors.KNNBuilder : k-nearest-neighbor builder class. """ transform_enum = Transform.NONE if transform is None else Transform(transform) builder = KNNBuilder( @@ -574,7 +574,7 @@ def spatial_neighbors_radius( See Also -------- spatial_neighbors_from_builder : Use :class:`~squidpy.gr.neighbors.RadiusBuilder` directly for advanced customization. - gr.neighbors.RadiusBuilder : radius-based builder class. + squidpy.gr.neighbors.RadiusBuilder : radius-based builder class. """ transform_enum = Transform.NONE if transform is None else Transform(transform) builder = RadiusBuilder( @@ -643,7 +643,7 @@ def spatial_neighbors_delaunay( See Also -------- spatial_neighbors_from_builder : Use :class:`~squidpy.gr.neighbors.DelaunayBuilder` directly for advanced customization. - gr.neighbors.DelaunayBuilder : Delaunay triangulation builder class. + squidpy.gr.neighbors.DelaunayBuilder : Delaunay triangulation builder class. """ transform_enum = Transform.NONE if transform is None else Transform(transform) builder = DelaunayBuilder( @@ -728,7 +728,7 @@ def spatial_neighbors_grid( See Also -------- spatial_neighbors_from_builder : Use :class:`~squidpy.gr.neighbors.GridBuilder` directly for advanced customization. - gr.neighbors.GridBuilder : grid-based builder class. + squidpy.gr.neighbors.GridBuilder : grid-based builder class. """ assert_positive(n_rings, name="n_rings") assert_positive(n_neighs, name="n_neighs") From 5a02b5958eae9d85aee489caa71d242950dd5053 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Mon, 13 Apr 2026 07:46:25 +0200 Subject: [PATCH 40/54] mark as TODO's --- src/squidpy/gr/_build.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 3d1df5c9a..b50b5262b 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -105,7 +105,7 @@ def _resolve_graph_builder( ) return GridBuilder(n_neighs=n_neighs, **common, n_rings=n_rings, delaunay=delaunay) if delaunay: - # below check should be removed once legacy mode spatial_neighbors is deprecated + # TODO: below check should be removed once legacy mode spatial_neighbors is deprecated if n_neighs_was_set: warnings.warn( "Parameter `n_neighs` is ignored when `delaunay=True` and will be removed in squidpy v2.0.0.", @@ -114,7 +114,7 @@ def _resolve_graph_builder( ) return DelaunayBuilder(**common, radius=radius, percentile=percentile) if radius is not None: - # below check should be removed once legacy mode spatial_neighbors is deprecated + # TODO: below check should be removed once legacy mode spatial_neighbors is deprecated if n_neighs_was_set: warnings.warn( "Parameter `n_neighs` is ignored when `radius` is set and will be removed in squidpy v2.0.0.", From 3af98ccd23a4adea348b88f317d7016b5aa6f18b Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Mon, 13 Apr 2026 07:55:40 +0200 Subject: [PATCH 41/54] rename sugg --- src/squidpy/gr/_build.py | 51 +++++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index b50b5262b..3da4cf9a5 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -126,7 +126,7 @@ def _resolve_graph_builder( def _resolve_spatial_data( - adata: AnnData | SpatialData, + data: AnnData | SpatialData, *, spatial_key: str, elements_to_coordinate_systems: dict[str, str] | None, @@ -134,20 +134,21 @@ def _resolve_spatial_data( library_key: str | None, ) -> tuple[AnnData, str | None]: """Resolve SpatialData to AnnData, returning (adata, library_key).""" - if isinstance(adata, SpatialData): + if isinstance(data, SpatialData): + sdata = data assert elements_to_coordinate_systems is not None, ( - "Since `adata` is a :class:`spatialdata.SpatialData`, `elements_to_coordinate_systems` must not be `None`." + "Since `data` is a :class:`spatialdata.SpatialData`, `elements_to_coordinate_systems` must not be `None`." ) assert table_key is not None, ( - "Since `adata` is a :class:`spatialdata.SpatialData`, `table_key` must not be `None`." + "Since `data` is a :class:`spatialdata.SpatialData`, `table_key` must not be `None`." ) - elements, table = match_element_to_table(adata, list(elements_to_coordinate_systems), table_key) - assert table.obs_names.equals(adata.tables[table_key].obs_names), ( + elements, table = match_element_to_table(sdata, list(elements_to_coordinate_systems), table_key) + assert table.obs_names.equals(sdata.tables[table_key].obs_names), ( "The spatialdata table must annotate all elements keys. Some elements are missing, please check the `elements_to_coordinate_systems` dictionary." ) - regions, region_key, instance_key = get_table_keys(adata.tables[table_key]) + regions, region_key, instance_key = get_table_keys(sdata.tables[table_key]) regions = [regions] if isinstance(regions, str) else regions - ordered_regions_in_table = adata.tables[table_key].obs[region_key].unique() + ordered_regions_in_table = sdata.tables[table_key].obs[region_key].unique() # TODO: remove this after https://github.com/scverse/spatialdata/issues/614 remove_centroids = {} @@ -163,7 +164,7 @@ def _resolve_spatial_data( elem_instances.append(element_instances) element_instances = pd.concat(elem_instances) - if (not np.all(element_instances.values == adata.tables[table_key].obs[instance_key].values)) or ( + if (not np.all(element_instances.values == sdata.tables[table_key].obs[instance_key].values)) or ( not np.all(ordered_regions_in_table == regions) ): raise ValueError( @@ -172,16 +173,18 @@ def _resolve_spatial_data( centroids = [] for region_ in ordered_regions_in_table: cs = elements_to_coordinate_systems[region_] - centroid = get_centroids(adata[region_], coordinate_system=cs)[["x", "y"]].compute() + centroid = get_centroids(sdata[region_], coordinate_system=cs)[["x", "y"]].compute() # TODO: remove this after https://github.com/scverse/spatialdata/issues/614 if remove_centroids[region_]: centroid = centroid[1:].copy() centroids.append(centroid) - adata.tables[table_key].obsm[spatial_key] = np.concatenate(centroids) - adata = adata.tables[table_key] + sdata.tables[table_key].obsm[spatial_key] = np.concatenate(centroids) + adata = sdata.tables[table_key] library_key = region_key + else: + adata = data return adata, library_key @@ -381,7 +384,7 @@ def spatial_neighbors( @d.dedent def spatial_neighbors_from_builder( - adata: AnnData | SpatialData, + data: AnnData | SpatialData, builder: GraphBuilder[Any, Any], *, spatial_key: str = Key.obsm.spatial, @@ -429,7 +432,7 @@ def spatial_neighbors_from_builder( squidpy.gr.neighbors.GraphBuilder : Base builder interface. Inherit from this or :class:`~squidpy.gr.neighbors.GraphBuilderCSR` to implement custom graph construction. """ adata, library_key = _prepare_spatial_neighbors_input( - adata, + data, spatial_key=spatial_key, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, @@ -446,7 +449,7 @@ def spatial_neighbors_from_builder( def _prepare_spatial_neighbors_input( - adata: AnnData | SpatialData, + data: AnnData | SpatialData, *, spatial_key: str, elements_to_coordinate_systems: dict[str, str] | None, @@ -455,7 +458,7 @@ def _prepare_spatial_neighbors_input( ) -> tuple[AnnData, str | None]: """Resolve input data and validate the requested spatial basis.""" adata, library_key = _resolve_spatial_data( - adata, + data, spatial_key=spatial_key, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, @@ -467,7 +470,7 @@ def _prepare_spatial_neighbors_input( @d.dedent def spatial_neighbors_knn( - adata: AnnData | SpatialData, + data: AnnData | SpatialData, *, spatial_key: str = Key.obsm.spatial, elements_to_coordinate_systems: dict[str, str] | None = None, @@ -515,7 +518,7 @@ def spatial_neighbors_knn( set_diag=set_diag, ) adata, library_key = _prepare_spatial_neighbors_input( - adata, + data, spatial_key=spatial_key, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, @@ -533,7 +536,7 @@ def spatial_neighbors_knn( @d.dedent def spatial_neighbors_radius( - adata: AnnData | SpatialData, + data: AnnData | SpatialData, *, spatial_key: str = Key.obsm.spatial, elements_to_coordinate_systems: dict[str, str] | None = None, @@ -584,7 +587,7 @@ def spatial_neighbors_radius( set_diag=set_diag, ) adata, library_key = _prepare_spatial_neighbors_input( - adata, + data, spatial_key=spatial_key, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, @@ -602,7 +605,7 @@ def spatial_neighbors_radius( @d.dedent def spatial_neighbors_delaunay( - adata: AnnData | SpatialData, + data: AnnData | SpatialData, *, spatial_key: str = Key.obsm.spatial, elements_to_coordinate_systems: dict[str, str] | None = None, @@ -653,7 +656,7 @@ def spatial_neighbors_delaunay( set_diag=set_diag, ) adata, library_key = _prepare_spatial_neighbors_input( - adata, + data, spatial_key=spatial_key, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, @@ -671,7 +674,7 @@ def spatial_neighbors_delaunay( @d.dedent def spatial_neighbors_grid( - adata: AnnData | SpatialData, + data: AnnData | SpatialData, *, spatial_key: str = Key.obsm.spatial, elements_to_coordinate_systems: dict[str, str] | None = None, @@ -741,7 +744,7 @@ def spatial_neighbors_grid( set_diag=set_diag, ) adata, library_key = _prepare_spatial_neighbors_input( - adata, + data, spatial_key=spatial_key, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, From e8a19d7ef4209fef188b87013c9d0201fdbe7468 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Mon, 13 Apr 2026 08:39:47 +0200 Subject: [PATCH 42/54] add combine into API --- src/squidpy/gr/_build.py | 22 +++++++++------------- src/squidpy/gr/neighbors.py | 22 +++++++++++++++++++++- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 3da4cf9a5..e006fb7d2 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -3,7 +3,7 @@ from __future__ import annotations import warnings -from typing import Any, NamedTuple, cast +from typing import Any, Generic, NamedTuple, TypeVar import geopandas as gpd import numpy as np @@ -11,11 +11,6 @@ from anndata import AnnData from anndata.utils import make_index_unique from numba import njit -from scipy.sparse import ( - block_diag, - csr_matrix, - spmatrix, -) from shapely import LineString, MultiPolygon, Polygon from spatialdata import SpatialData from spatialdata._core.centroids import get_centroids @@ -57,11 +52,14 @@ ] -class SpatialNeighborsResult(NamedTuple): +GraphMatrixT = TypeVar("GraphMatrixT") + + +class SpatialNeighborsResult(NamedTuple, Generic[GraphMatrixT]): """Result of spatial_neighbors function.""" - connectivities: csr_matrix - distances: csr_matrix + connectivities: GraphMatrixT + distances: GraphMatrixT def _resolve_graph_builder( @@ -781,14 +779,12 @@ def _run_spatial_neighbors( f"Creating graph using `{builder.coord_type}` coordinates and `{builder.transform}` transform and `{len(libs)}` libraries." ) if library_key is not None: - mats: list[tuple[spmatrix, spmatrix]] = [] + mats: list[tuple[Any, Any]] = [] ixs: list[int] = [] for lib in libs: ixs.extend(np.where(adata.obs[library_key] == lib)[0]) mats.append(builder.build(adata[adata.obs[library_key] == lib].obsm[spatial_key])) - ixs = cast(list[int], np.argsort(ixs).tolist()) - adj = block_diag([m[0] for m in mats], format="csr")[ixs, :][:, ixs] - dst = block_diag([m[1] for m in mats], format="csr")[ixs, :][:, ixs] + adj, dst = builder.combine(mats, ixs) else: adj, dst = builder.build(adata.obsm[spatial_key]) diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index 2a1e80605..eee55b61d 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -7,7 +7,7 @@ import warnings from abc import ABC, abstractmethod -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from itertools import chain from typing import Generic, TypeVar, cast @@ -16,6 +16,7 @@ from numba import njit, prange from scipy.sparse import ( SparseEfficiencyWarning, + block_diag, csr_array, csr_matrix, isspmatrix_csr, @@ -75,6 +76,15 @@ def apply_percentile(self, adj: GraphMatrixT, dst: GraphMatrixT) -> tuple[GraphM def apply_transform(self, adj: GraphMatrixT, dst: GraphMatrixT) -> tuple[GraphMatrixT, GraphMatrixT]: return adj, dst + def combine( + self, + mats: Sequence[tuple[GraphMatrixT, GraphMatrixT]], + ixs: Sequence[int], + ) -> tuple[GraphMatrixT, GraphMatrixT]: + raise NotImplementedError( + "Using `library_key` with this graph builder is not implemented yet." + ) + class GraphBuilderCSR(GraphBuilder[NDArrayA, csr_matrix], ABC): """CSR-based graph construction strategy. @@ -127,6 +137,16 @@ def apply_transform(self, adj: csr_matrix, dst: csr_matrix) -> tuple[csr_matrix, raise NotImplementedError(f"Transform `{self.transform}` is not yet implemented.") + def combine( + self, + mats: Sequence[tuple[csr_matrix, csr_matrix]], + ixs: Sequence[int], + ) -> tuple[csr_matrix, csr_matrix]: + order = cast(list[int], np.argsort(ixs).tolist()) + adj = block_diag([m[0] for m in mats], format="csr")[order, :][:, order] + dst = block_diag([m[1] for m in mats], format="csr")[order, :][:, order] + return cast(csr_matrix, adj), cast(csr_matrix, dst) + class KNNBuilder(GraphBuilderCSR): """Build a generic k-nearest-neighbor spatial graph. From ac682a3c12e1ce897c51641c449918d8043a1f40 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 06:40:28 +0000 Subject: [PATCH 43/54] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/squidpy/gr/neighbors.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index eee55b61d..b6c404548 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -81,9 +81,7 @@ def combine( mats: Sequence[tuple[GraphMatrixT, GraphMatrixT]], ixs: Sequence[int], ) -> tuple[GraphMatrixT, GraphMatrixT]: - raise NotImplementedError( - "Using `library_key` with this graph builder is not implemented yet." - ) + raise NotImplementedError("Using `library_key` with this graph builder is not implemented yet.") class GraphBuilderCSR(GraphBuilder[NDArrayA, csr_matrix], ABC): From 0f3329cf93325e9b91e94ffdfc8a0f22f4b7ad92 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Mon, 13 Apr 2026 08:43:30 +0200 Subject: [PATCH 44/54] update docs --- docs/extensibility.md | 6 ++++-- src/squidpy/gr/_build.py | 3 +++ src/squidpy/gr/neighbors.py | 21 +++++++++++++++++---- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/extensibility.md b/docs/extensibility.md index aa2845bf5..798d8c714 100644 --- a/docs/extensibility.md +++ b/docs/extensibility.md @@ -20,10 +20,12 @@ The `squidpy.gr.neighbors` module exposes two builder base classes: | {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.apply_filters` | no | Post-process the raw ``adj``/``dst`` before percentile filtering and transform. | | {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.apply_percentile` | no | Override percentile handling when the backend needs custom behavior. | | {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.apply_transform` | no | Override transform handling when the backend needs custom behavior. | +| {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.combine` | no | Combine per-library results when using ``library_key``. If you do not need ``library_key`` support, leaving this unimplemented is fine. | -The generic builder only defines the pipeline. The CSR-specialized builder adds the -built-in CSR behavior for percentile filtering, adjacency transforms, and +The generic builder only defines the pipeline. The CSR-specialized builder adds +the built-in CSR behavior for percentile filtering, adjacency transforms, +multi-library ``library_key`` combination, and {class}`~scipy.sparse.SparseEfficiencyWarning` suppression. Here ``adj`` and ``dst`` are square sparse matrices of shape ``(n_obs, n_obs)`` diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index e006fb7d2..25dfd0826 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -410,6 +410,9 @@ def spatial_neighbors_from_builder( {{class}}`~squidpy.gr.neighbors.GraphBuilderCSR`, while custom backends can implement the more generic {{class}}`~squidpy.gr.neighbors.GraphBuilder` interface directly. + Custom builders only need to implement multi-library support when using + ``library_key``; otherwise leaving + :meth:`~squidpy.gr.neighbors.GraphBuilder.combine` unimplemented is fine. %(spatial_key)s %(sdata_params)s %(library_key)s diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index b6c404548..94724a010 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -38,7 +38,12 @@ class GraphBuilder(ABC, Generic[CoordT, GraphMatrixT]): - """Base class for spatial graph construction strategies.""" + """Base class for spatial graph construction strategies. + + Custom builders must implement :meth:`build_graph`. Overriding + :meth:`combine` is optional and only needed to support multi-library graph + construction via ``library_key``. + """ def __init__( self, @@ -81,15 +86,23 @@ def combine( mats: Sequence[tuple[GraphMatrixT, GraphMatrixT]], ixs: Sequence[int], ) -> tuple[GraphMatrixT, GraphMatrixT]: - raise NotImplementedError("Using `library_key` with this graph builder is not implemented yet.") + """Combine per-library results into a single graph. + + Override this only if the builder should support multi-library graph + construction via ``library_key``. + """ + raise NotImplementedError( + "Using `library_key` with this graph builder is not implemented yet." + ) class GraphBuilderCSR(GraphBuilder[NDArrayA, csr_matrix], ABC): """CSR-based graph construction strategy. Specializes :class:`GraphBuilder` for sparse CSR matrix output. Adds built-in handling - for percentile-based edge pruning, adjacency transforms (spectral/cosine), and - SparseEfficiencyWarning suppression. All built-in concrete builders + for percentile-based edge pruning, adjacency transforms (spectral/cosine), + SparseEfficiencyWarning suppression, and multi-library ``library_key`` + combination. All built-in concrete builders (:class:`KNNBuilder`, :class:`RadiusBuilder`, :class:`DelaunayBuilder`, :class:`GridBuilder`) inherit from this class. From 00ec2b068525aabadaab5d04adb43f5725138f74 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 06:46:13 +0000 Subject: [PATCH 45/54] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/squidpy/gr/neighbors.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index 94724a010..b5f2aad16 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -91,9 +91,7 @@ def combine( Override this only if the builder should support multi-library graph construction via ``library_key``. """ - raise NotImplementedError( - "Using `library_key` with this graph builder is not implemented yet." - ) + raise NotImplementedError("Using `library_key` with this graph builder is not implemented yet.") class GraphBuilderCSR(GraphBuilder[NDArrayA, csr_matrix], ABC): From 042f749c37d9c8d39eb0caef6b51a2f07c3a7e28 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:51:22 +0200 Subject: [PATCH 46/54] [pre-commit.ci] pre-commit autoupdate (#1149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/biomejs/pre-commit: v2.4.9 → v2.4.10](https://github.com/biomejs/pre-commit/compare/v2.4.9...v2.4.10) - [github.com/tox-dev/pyproject-fmt: v2.20.0 → v2.21.0](https://github.com/tox-dev/pyproject-fmt/compare/v2.20.0...v2.21.0) - [github.com/astral-sh/ruff-pre-commit: v0.15.8 → v0.15.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.8...v0.15.9) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- pyproject.toml | 42 ++++++++++++++++++++--------------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 118cb1b8a..401c82b0f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,16 +7,16 @@ default_stages: minimum_pre_commit_version: 2.16.0 repos: - repo: https://github.com/biomejs/pre-commit - rev: v2.4.9 + rev: v2.4.10 hooks: - id: biome-format exclude: ^\.cruft\.json$ # inconsistent indentation with cruft - file never to be modified manually. - repo: https://github.com/tox-dev/pyproject-fmt - rev: v2.20.0 + rev: v2.21.0 hooks: - id: pyproject-fmt - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.8 + rev: v0.15.9 hooks: - id: ruff-check types_or: [python, pyi, jupyter] diff --git a/pyproject.toml b/pyproject.toml index 1e264c809..06e9dfc5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -133,6 +133,27 @@ build.targets.wheel.packages = [ "src/squidpy" ] metadata.allow-direct-references = true version.source = "vcs" +[tool.pixi] +workspace.channels = [ "conda-forge" ] +workspace.platforms = [ "linux-64", "osx-arm64" ] +dependencies.python = ">=3.11" +pypi-dependencies.squidpy = { path = ".", editable = true } +tasks.format = "ruff format ." +tasks.kernel-install = 'python -m ipykernel install --user --name pixi-dev --display-name "squidpy (dev)"' +tasks.lab = "jupyter lab" +tasks.lint = "ruff check ." +tasks.pre-commit = "pre-commit run" +tasks.pre-commit-install = "pre-commit install" +tasks.test = "pytest -v --color=yes --tb=short --durations=10" +feature.py311.dependencies.python = "3.11.*" +feature.py313.dependencies.python = "3.13.*" +environments.default = { features = [ "py313" ], solve-group = "py313" } +environments.dev-py311 = { features = [ "dev", "test", "py311" ], solve-group = "py311" } +environments.dev-py313 = { features = [ "dev", "test", "py313" ], solve-group = "py313" } +environments.docs-py311 = { features = [ "docs", "py311" ], solve-group = "py311" } +environments.docs-py313 = { features = [ "docs", "py313" ], solve-group = "py313" } +environments.test-py313 = { features = [ "test", "py313" ], solve-group = "py313" } + [tool.ruff] line-length = 120 exclude = [ @@ -278,24 +299,3 @@ skip = [ "docs/references.md", "docs/notebooks/example.ipynb", ] - -[tool.pixi] -dependencies.python = ">=3.11" -environments.dev-py311 = { features = [ "dev", "test", "py311" ], solve-group = "py311" } -environments.docs-py311 = { features = [ "docs", "py311" ], solve-group = "py311" } -environments.default = { features = [ "py313" ], solve-group = "py313" } -environments.dev-py313 = { features = [ "dev", "test", "py313" ], solve-group = "py313" } -environments.docs-py313 = { features = [ "docs", "py313" ], solve-group = "py313" } -environments.test-py313 = { features = [ "test", "py313" ], solve-group = "py313" } -feature.py311.dependencies.python = "3.11.*" -feature.py313.dependencies.python = "3.13.*" -pypi-dependencies.squidpy = { path = ".", editable = true } -tasks.lab = "jupyter lab" -tasks.kernel-install = 'python -m ipykernel install --user --name pixi-dev --display-name "squidpy (dev)"' -tasks.test = "pytest -v --color=yes --tb=short --durations=10" -tasks.lint = "ruff check ." -tasks.format = "ruff format ." -tasks.pre-commit-install = "pre-commit install" -tasks.pre-commit = "pre-commit run" -workspace.channels = [ "conda-forge" ] -workspace.platforms = [ "osx-arm64", "linux-64" ] From 1a87841815549635e10e55c27e52dd8db33fa059 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Fri, 10 Apr 2026 21:33:40 +0000 Subject: [PATCH 47/54] Functions to QC histopathology images (#1036) --- src/squidpy/experimental/im/__init__.py | 4 + src/squidpy/experimental/im/_detect_tissue.py | 6 +- .../experimental/im/_intensity_metrics.py | 134 ++++++ src/squidpy/experimental/im/_make_tiles.py | 175 +------ src/squidpy/experimental/im/_qc_image.py | 438 ++++++++++++++++++ src/squidpy/experimental/im/_qc_metrics.py | 98 ++++ .../experimental/im/_sharpness_metrics.py | 94 ++++ src/squidpy/experimental/im/_utils.py | 199 +++++++- src/squidpy/experimental/pl/__init__.py | 4 +- src/squidpy/experimental/pl/_qc_image.py | 199 ++++++++ tests/_images/QCImage_calc_qc_image_hne.png | Bin 0 -> 21230 bytes .../_images/QCImage_calc_qc_image_not_hne.png | Bin 0 -> 21063 bytes tests/_images/QCImage_plot_qc_image.png | Bin 0 -> 17741 bytes .../_images/QCSharpness_calc_qc_sharpness.png | Bin 19379 -> 0 bytes .../_images/QCSharpness_plot_qc_sharpness.png | Bin 30929 -> 0 bytes tests/experimental/test_qc_image.py | 216 +++++++++ 16 files changed, 1406 insertions(+), 161 deletions(-) create mode 100644 src/squidpy/experimental/im/_intensity_metrics.py create mode 100644 src/squidpy/experimental/im/_qc_image.py create mode 100644 src/squidpy/experimental/im/_qc_metrics.py create mode 100644 src/squidpy/experimental/im/_sharpness_metrics.py create mode 100644 src/squidpy/experimental/pl/_qc_image.py create mode 100644 tests/_images/QCImage_calc_qc_image_hne.png create mode 100644 tests/_images/QCImage_calc_qc_image_not_hne.png create mode 100644 tests/_images/QCImage_plot_qc_image.png delete mode 100644 tests/_images/QCSharpness_calc_qc_sharpness.png delete mode 100644 tests/_images/QCSharpness_plot_qc_sharpness.png create mode 100644 tests/experimental/test_qc_image.py diff --git a/src/squidpy/experimental/im/__init__.py b/src/squidpy/experimental/im/__init__.py index b88006688..24f7cf6ee 100644 --- a/src/squidpy/experimental/im/__init__.py +++ b/src/squidpy/experimental/im/__init__.py @@ -7,12 +7,16 @@ detect_tissue, ) from ._make_tiles import make_tiles, make_tiles_from_spots +from ._qc_image import qc_image +from ._qc_metrics import QCMetric __all__ = [ "BackgroundDetectionParams", "FelzenszwalbParams", + "QCMetric", "WekaParams", "detect_tissue", "make_tiles", "make_tiles_from_spots", + "qc_image", ] diff --git a/src/squidpy/experimental/im/_detect_tissue.py b/src/squidpy/experimental/im/_detect_tissue.py index 4b8bdc98a..c8aa34590 100644 --- a/src/squidpy/experimental/im/_detect_tissue.py +++ b/src/squidpy/experimental/im/_detect_tissue.py @@ -24,7 +24,7 @@ from squidpy._utils import _ensure_dim_order, _get_scale_factors, _yx_from_shape -from ._utils import _flatten_channels, _get_element_data +from ._utils import flatten_channels, get_element_data class DetectTissueMethod(enum.Enum): @@ -357,7 +357,7 @@ def detect_tissue( # Load smallest available or explicit scale img_node = sdata.images[image_key] - img_da = _get_element_data(img_node, scale if manual_scale else "auto", "image", image_key) + img_da = get_element_data(img_node, scale if manual_scale else "auto", "image", image_key) img_src = _ensure_dim_order(img_da, "yxc") src_h = int(img_src.sizes["y"]) src_w = int(img_src.sizes["x"]) @@ -375,7 +375,7 @@ def detect_tissue( # Channel flattening (greyscale) for threshold-based methods img_grey = None if method != DetectTissueMethod.WEKA: - img_grey_da: xr.DataArray = _flatten_channels(img=img_src, channel_format=channel_format) + img_grey_da: xr.DataArray = flatten_channels(img=img_src, channel_format=channel_format) if need_downscale: logger.info("Downscaling for faster computation.") img_grey = _downscale_with_dask(img_grey=img_grey_da, target_pixels=auto_max_pixels) diff --git a/src/squidpy/experimental/im/_intensity_metrics.py b/src/squidpy/experimental/im/_intensity_metrics.py new file mode 100644 index 000000000..2657bb41a --- /dev/null +++ b/src/squidpy/experimental/im/_intensity_metrics.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import numpy as np + +# --- Intensity metrics (grayscale input) --- + + +def brightness_mean(block: np.ndarray) -> np.ndarray: + """Mean pixel intensity of a grayscale tile.""" + return np.array([[float(block.mean())]], dtype=np.float32) + + +def brightness_std(block: np.ndarray) -> np.ndarray: + """Standard deviation of pixel intensity of a grayscale tile.""" + return np.array([[float(block.std())]], dtype=np.float32) + + +def entropy(block: np.ndarray) -> np.ndarray: + """Shannon entropy of pixel intensity histogram.""" + arr = block.ravel() + lo, hi = float(arr.min()), float(arr.max()) + if hi - lo < 1e-10: + return np.array([[0.0]], dtype=np.float32) + # Quantize to 256 bins directly without storing intermediate normalized array + bins = np.clip(((arr - lo) * (255.0 / (hi - lo))).astype(np.int32), 0, 255) + counts = np.bincount(bins, minlength=256) + probs = counts[counts > 0].astype(np.float64) + probs /= probs.sum() + ent = -float(np.dot(probs, np.log2(probs))) + return np.array([[ent]], dtype=np.float32) + + +# --- Staining metrics (RGB input, H&E only) --- + + +def rgb_to_hed(block_rgb: np.ndarray) -> np.ndarray: + """Convert RGB tile to HED colour space using Beer-Lambert deconvolution. + + Parameters + ---------- + block_rgb + (ty, tx, 3) float32 array in [0, 1]. + + Returns + ------- + (ty, tx, 3) float64 array with channels H, E, D. + """ + from skimage.color import rgb2hed + + rgb_clipped = np.clip(block_rgb, 0.0, 1.0) + return rgb2hed(rgb_clipped) + + +def hed_metrics(block: np.ndarray) -> np.ndarray: + """Return all HED-derived metrics for one RGB tile.""" + hed = rgb_to_hed(block) + h = hed[..., 0] + e = hed[..., 1] + + return np.array( + [ + [ + [ + float(h.mean()), + float(h.std()), + float(e.mean()), + float(e.std()), + float(np.abs(h).mean() / (np.abs(e).mean() + 1e-10)), + ] + ] + ], + dtype=np.float32, + ) + + +def hematoxylin_mean(block: np.ndarray) -> np.ndarray: + """Mean hematoxylin channel intensity.""" + hed = rgb_to_hed(block) + return np.array([[float(hed[..., 0].mean())]], dtype=np.float32) + + +def hematoxylin_std(block: np.ndarray) -> np.ndarray: + """Std of hematoxylin channel intensity.""" + hed = rgb_to_hed(block) + return np.array([[float(hed[..., 0].std())]], dtype=np.float32) + + +def eosin_mean(block: np.ndarray) -> np.ndarray: + """Mean eosin channel intensity.""" + hed = rgb_to_hed(block) + return np.array([[float(hed[..., 1].mean())]], dtype=np.float32) + + +def eosin_std(block: np.ndarray) -> np.ndarray: + """Std of eosin channel intensity.""" + hed = rgb_to_hed(block) + return np.array([[float(hed[..., 1].std())]], dtype=np.float32) + + +def he_ratio(block: np.ndarray) -> np.ndarray: + """Ratio of hematoxylin to eosin mean intensity.""" + hed = rgb_to_hed(block) + h_mean = float(np.abs(hed[..., 0]).mean()) + e_mean = float(np.abs(hed[..., 1]).mean()) + ratio = h_mean / (e_mean + 1e-10) + return np.array([[ratio]], dtype=np.float32) + + +# --- Artifact metrics (RGB input, H&E only) --- + + +def fold_fraction(block: np.ndarray) -> np.ndarray: + """Fraction of pixels identified as tissue folds. + + Uses HSV thresholds tuned for H&E staining: saturation > 0.4 and + value < 0.3 captures the dark, saturated appearance of folded tissue. + """ + from skimage.color import rgb2hsv + + rgb_clipped = np.clip(block, 0.0, 1.0) + hsv = rgb2hsv(rgb_clipped) + sat = hsv[..., 1] + val = hsv[..., 2] + fold_mask = (sat > 0.4) & (val < 0.3) + frac = float(fold_mask.sum()) / max(fold_mask.size, 1) + return np.array([[frac]], dtype=np.float32) + + +# --- Tissue coverage (mask input) --- + + +def tissue_fraction(block: np.ndarray) -> np.ndarray: + """Fraction of pixels that are tissue (nonzero) in a binary mask tile.""" + return np.array([[float(block.mean())]], dtype=np.float32) diff --git a/src/squidpy/experimental/im/_make_tiles.py b/src/squidpy/experimental/im/_make_tiles.py index ef04d6302..6323c48ad 100644 --- a/src/squidpy/experimental/im/_make_tiles.py +++ b/src/squidpy/experimental/im/_make_tiles.py @@ -1,9 +1,5 @@ from __future__ import annotations -import itertools -from typing import Literal - -import dask.array as da import geopandas as gpd import numpy as np import pandas as pd @@ -18,101 +14,16 @@ from squidpy._utils import _yx_from_shape from squidpy._validators import assert_in_range, assert_key_in_sdata, assert_positive - -from ._utils import _get_element_data +from squidpy.experimental.im._utils import ( + TileGrid, + get_element_data, + get_mask_materialized, + save_tile_grid_to_shapes, +) __all__ = ["make_tiles", "make_tiles_from_spots"] -class _TileGrid: - """Immutable tile grid definition with cached bounds and centroids.""" - - def __init__( - self, - H: int, - W: int, - tile_size: Literal["auto"] | tuple[int, int] = "auto", - target_tiles: int = 100, - offset_y: int = 0, - offset_x: int = 0, - ): - self.H = H - self.W = W - if tile_size == "auto": - size = max(min(self.H // target_tiles, self.W // target_tiles), 100) - self.ty = int(size) - self.tx = int(size) - else: - self.ty = int(tile_size[0]) - self.tx = int(tile_size[1]) - self.offset_y = offset_y - self.offset_x = offset_x - # Calculate number of tiles needed to cover entire image, accounting for offset - # The grid starts at offset_y, offset_x (can be negative) - # We need tiles from min(0, offset_y) to at least H - # So total coverage needed is from min(0, offset_y) to H - grid_start_y = min(0, self.offset_y) - grid_start_x = min(0, self.offset_x) - total_h_needed = self.H - grid_start_y - total_w_needed = self.W - grid_start_x - self.tiles_y = (total_h_needed + self.ty - 1) // self.ty - self.tiles_x = (total_w_needed + self.tx - 1) // self.tx - # Cache immutable derived values - self._indices = np.array([[iy, ix] for iy in range(self.tiles_y) for ix in range(self.tiles_x)], dtype=int) - self._names = [f"tile_x{ix}_y{iy}" for iy in range(self.tiles_y) for ix in range(self.tiles_x)] - self._bounds = self._compute_bounds() - self._centroids_polys = self._compute_centroids_and_polygons() - - def indices(self) -> np.ndarray: - return self._indices - - def names(self) -> list[str]: - return self._names - - def bounds(self) -> np.ndarray: - return self._bounds - - def _compute_bounds(self) -> np.ndarray: - b: list[list[int]] = [] - for iy, ix in itertools.product(range(self.tiles_y), range(self.tiles_x)): - y0 = iy * self.ty + self.offset_y - x0 = ix * self.tx + self.offset_x - y1 = ((iy + 1) * self.ty + self.offset_y) if iy < self.tiles_y - 1 else self.H - x1 = ((ix + 1) * self.tx + self.offset_x) if ix < self.tiles_x - 1 else self.W - # Clamp bounds to image dimensions - y0 = max(0, min(y0, self.H)) - x0 = max(0, min(x0, self.W)) - y1 = max(0, min(y1, self.H)) - x1 = max(0, min(x1, self.W)) - b.append([y0, x0, y1, x1]) - return np.array(b, dtype=int) - - def centroids_and_polygons(self) -> tuple[np.ndarray, list[Polygon]]: - return self._centroids_polys - - def _compute_centroids_and_polygons(self) -> tuple[np.ndarray, list[Polygon]]: - cents: list[list[float]] = [] - polys: list[Polygon] = [] - for y0, x0, y1, x1 in self._bounds: - cy = (y0 + y1) / 2 - cx = (x0 + x1) / 2 - cents.append([cy, cx]) - polys.append(Polygon([(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)])) - return np.array(cents, dtype=float), polys - - def rechunk_and_pad(self, arr_yx: da.Array) -> da.Array: - if arr_yx.ndim != 2: - raise ValueError("Expected a 2D array shaped (y, x).") - pad_y = self.tiles_y * self.ty - int(arr_yx.shape[0]) - pad_x = self.tiles_x * self.tx - int(arr_yx.shape[1]) - a = arr_yx.rechunk((self.ty, self.tx)) - return da.pad(a, ((0, pad_y), (0, pad_x)), mode="edge") if (pad_y > 0 or pad_x > 0) else a - - def coarsen(self, arr_yx: da.Array, reduce: Literal["mean", "sum"] = "mean") -> da.Array: - reducer = np.mean if reduce == "mean" else np.sum - return da.coarsen(reducer, arr_yx, {0: self.ty, 1: self.tx}, trim_excess=False) - - class _SpotTileGrid: """Tile container for Visium spots, used with ``_filter_tiles``.""" @@ -176,9 +87,9 @@ def _get_largest_scale_dimensions( """ img_node = sdata.images[image_key] - # Use _get_element_data with "scale0" which is always the largest scale + # Use get_element_data with "scale0" which is always the largest scale # It handles both datatree (multiscale) and single-scale images - img_da = _get_element_data(img_node, "scale0", "image", image_key) + img_da = get_element_data(img_node, "scale0", "image", image_key) # Get spatial dimensions (y, x) if "y" in img_da.sizes and "x" in img_da.sizes: @@ -208,34 +119,12 @@ def _choose_label_scale_for_image(label_node: Labels2DModel, target_hw: tuple[in def _save_tiles_to_shapes( sdata: sd.SpatialData, - tg: _TileGrid, + tg: TileGrid, image_key: str, shapes_key: str, ) -> None: """Save a TileGrid to sdata.shapes as a GeoDataFrame.""" - tile_indices = tg.indices() - pixel_bounds = tg.bounds() - _, polys = tg.centroids_and_polygons() - - tile_gdf = gpd.GeoDataFrame( - { - "tile_id": tg.names(), - "tile_y": tile_indices[:, 0], - "tile_x": tile_indices[:, 1], - "pixel_y0": pixel_bounds[:, 0], - "pixel_x0": pixel_bounds[:, 1], - "pixel_y1": pixel_bounds[:, 2], - "pixel_x1": pixel_bounds[:, 3], - "geometry": polys, - }, - geometry="geometry", - ) - - sdata.shapes[shapes_key] = ShapesModel.parse(tile_gdf) - # we know that a) the element exists and b) it has at least an Identity transformation - transformations = get_transformation(sdata.images[image_key], get_all=True) - set_transformation(sdata.shapes[shapes_key], transformations, set_all=True) - logger.info(f"Saved tile grid as 'sdata.shapes[\"{shapes_key}\"]'") + save_tile_grid_to_shapes(sdata, tg, shapes_key, copy_transforms_from_key=image_key) def _save_spot_tiles_to_shapes( @@ -375,7 +264,7 @@ def make_tiles( mask_key_for_grid = default_mask_key else: try: - from ._detect_tissue import detect_tissue + from squidpy.experimental.im._detect_tissue import detect_tissue detect_tissue( sdata, @@ -420,7 +309,7 @@ def make_tiles( classification_mask_key, ) try: - from ._detect_tissue import detect_tissue + from squidpy.experimental.im._detect_tissue import detect_tissue detect_tissue( sdata, @@ -568,7 +457,7 @@ def make_tiles_from_spots( classification_mask_key, ) try: - from ._detect_tissue import detect_tissue + from squidpy.experimental.im._detect_tissue import detect_tissue detect_tissue( sdata, @@ -639,7 +528,7 @@ def make_tiles_from_spots( def _filter_tiles( sdata: sd.SpatialData, - tg: _TileGrid, + tg: TileGrid, image_key: str | None, *, tissue_mask_key: str | None = None, @@ -691,7 +580,7 @@ def _filter_tiles( else: raise ValueError("tissue_mask_key must be provided when image_key is None.") assert_key_in_sdata(sdata, mask_key, attr="labels") - mask = _get_mask_from_labels(sdata, mask_key, scale) + mask = get_mask_materialized(sdata, mask_key, scale) H_mask, W_mask = mask.shape # Check tissue coverage for each tile @@ -756,7 +645,7 @@ def _make_tiles( tile_size: tuple[int, int] = (224, 224), center_grid_on_tissue: bool = False, scale: str = "auto", -) -> _TileGrid: +) -> TileGrid: """Construct a tile grid for an image, optionally centered on a tissue mask. Callers must validate *image_key* before calling this helper. @@ -768,14 +657,14 @@ def _make_tiles( # Path 1: Regular grid starting from top-left if not center_grid_on_tissue or image_mask_key is None: - return _TileGrid(H, W, tile_size=tile_size) + return TileGrid(H, W, tile_size=tile_size) # Path 2: Center grid on tissue mask centroid assert_key_in_sdata(sdata, image_mask_key, attr="labels") # Get mask and compute centroid label_node = sdata.labels[image_mask_key] - mask_da = _get_element_data(label_node, scale, "label", image_mask_key) + mask_da = get_element_data(label_node, scale, "label", image_mask_key) # Convert to numpy array if needed if is_dask_collection(mask_da): @@ -807,7 +696,7 @@ def _make_tiles( mask_bool = mask > 0 if not mask_bool.any(): logger.warning("Mask is empty. Using regular grid starting from top-left.") - return _TileGrid(H, W, tile_size=tile_size) + return TileGrid(H, W, tile_size=tile_size) # Calculate centroid using center of mass y_coords, x_coords = np.where(mask_bool) @@ -822,7 +711,7 @@ def _make_tiles( offset_y = int(round(centroid_y - tile_center_y_standard)) offset_x = int(round(centroid_x - tile_center_x_standard)) - return _TileGrid(H, W, tile_size=tile_size, offset_y=offset_y, offset_x=offset_x) + return TileGrid(H, W, tile_size=tile_size, offset_y=offset_y, offset_x=offset_x) def _get_spot_coordinates( @@ -878,27 +767,3 @@ def _derive_tile_size_from_spots(coords: np.ndarray) -> tuple[int, int]: ) side = max(1, int(np.floor(row_spacing))) return side, side - - -def _get_mask_from_labels(sdata: sd.SpatialData, mask_key: str, scale: str) -> np.ndarray: - """Extract a 2D mask array from ``sdata.labels`` at the requested scale. - - Callers must validate *mask_key* before calling this helper. - """ - label_node = sdata.labels[mask_key] - mask_da = _get_element_data(label_node, scale, "label", mask_key) - - if is_dask_collection(mask_da): - mask_da = mask_da.compute() - - if isinstance(mask_da, xr.DataArray): - mask = np.asarray(mask_da.data) - else: - mask = np.asarray(mask_da) - - if mask.ndim > 2: - mask = mask.squeeze() - if mask.ndim != 2: - raise ValueError(f"Expected 2D mask with shape (y, x), got shape {mask.shape}") - - return mask diff --git a/src/squidpy/experimental/im/_qc_image.py b/src/squidpy/experimental/im/_qc_image.py new file mode 100644 index 000000000..a8010e87f --- /dev/null +++ b/src/squidpy/experimental/im/_qc_image.py @@ -0,0 +1,438 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import Literal + +import dask +import dask.array as da +import numpy as np +import pandas as pd +import xarray as xr +from anndata import AnnData +from dask.diagnostics import ProgressBar +from spatialdata import SpatialData +from spatialdata._logging import logger +from spatialdata.models import TableModel + +from squidpy._utils import _ensure_dim_order +from squidpy.experimental.im._intensity_metrics import hed_metrics +from squidpy.experimental.im._qc_metrics import _HNE_METRICS, InputKind, QCMetric, get_metric_info +from squidpy.experimental.im._utils import ( + TileGrid, + get_element_data, + get_mask_dask, + resolve_tissue_mask, + save_tile_grid_to_shapes, +) + +_DEFAULT_HNE_METRICS: list[QCMetric] = [ + QCMetric.TENENGRAD, + QCMetric.VAR_OF_LAPLACIAN, + QCMetric.ENTROPY, + QCMetric.BRIGHTNESS_MEAN, + QCMetric.HEMATOXYLIN_MEAN, + QCMetric.EOSIN_MEAN, +] + +_DEFAULT_GENERIC_METRICS: list[QCMetric] = [ + QCMetric.TENENGRAD, + QCMetric.VAR_OF_LAPLACIAN, + QCMetric.ENTROPY, + QCMetric.BRIGHTNESS_MEAN, +] + + +def qc_image( + sdata: SpatialData, + image_key: str, + *, + scale: str = "scale0", + metrics: QCMetric | list[QCMetric] | None = None, + tile_size: Literal["auto"] | tuple[int, int] = "auto", + is_hne: bool = True, + detect_outliers: bool = True, + detect_tissue: bool = True, + outlier_threshold: float = 0.1, + progress: bool = True, + tissue_mask_key: str | None = None, + preview: bool = True, +) -> None: + """ + Perform quality control analysis on an image. + + Computes tile-based QC metrics including sharpness, intensity, staining + (H&E only), artifact detection, and tissue coverage. + + Parameters + ---------- + sdata + SpatialData object containing the image. + image_key + Key of the image in ``sdata.images`` to analyze. + scale + Scale level to use for processing. Defaults to ``"scale0"``. + metrics + QC metrics to compute. Can be a single metric or list of metrics. + If ``None``, uses sensible defaults based on ``is_hne``. + tile_size + Size of tiles for analysis. If ``"auto"``, automatically determines size. + is_hne + Whether the image is H&E stained. Controls which metrics are available + and which defaults are used. If ``False`` and H&E-specific metrics are + explicitly requested, raises ``ValueError``. + detect_outliers + Whether to detect outlier tiles based on QC scores. + detect_tissue + Whether to detect tissue regions for context-aware outlier detection. + outlier_threshold + Percentile threshold (0-1) for outlier detection. A tile is flagged as + an outlier if its within-tissue percentile rank falls below this value. + For example, ``0.1`` flags the bottom 10% of tissue tiles (by their + worst sharpness metric) as outliers. Default is ``0.1``. + progress + Whether to show progress bar during computation. + tissue_mask_key + Key of the tissue mask in ``sdata.labels`` to use. If ``None``, the function will + check if ``"{image_key}_tissue"`` already exists in ``sdata.labels`` and reuse it. + If it doesn't exist, tissue detection will be performed and the mask will be added + to ``sdata.labels`` with key ``"{image_key}_tissue"``. If provided, the existing + mask at this key will be used. + preview + If ``True``, render a preview showing the image overlaid with outlier tiles + highlighted in red. Only shown when ``detect_outliers=True``. + + Returns + ------- + None + Results are stored in the following locations: + + - ``sdata.tables[f"qc_img_{image_key}"]``: AnnData object with QC scores + - ``sdata.shapes[f"qc_img_{image_key}_grid"]``: GeoDataFrame with tile geometries + - ``sdata.tables[...].uns["qc_image"]``: Metadata about the analysis + """ + # Parameter validation + if image_key not in sdata.images: + raise KeyError(f"Image key '{image_key}' not found in sdata.images") + + if metrics is None: + metrics = list(_DEFAULT_HNE_METRICS if is_hne else _DEFAULT_GENERIC_METRICS) + elif isinstance(metrics, QCMetric): + metrics = [metrics] + else: + metrics = list(metrics) + + if not isinstance(metrics, list) or not all(isinstance(m, QCMetric) for m in metrics): + available = ", ".join(m.value for m in QCMetric) + raise TypeError(f"metrics must be QCMetric or list of QCMetric. Available: {available}") + + # Validate H&E constraint + if not is_hne: + hne_requested = _HNE_METRICS & set(metrics) + if hne_requested: + names = ", ".join(m.value for m in hne_requested) + raise ValueError( + f"H&E-specific metrics ({names}) cannot be used when is_hne=False. " + f"Set is_hne=True or remove these metrics." + ) + + if not 0 < outlier_threshold < 1: + raise ValueError(f"outlier_threshold must be in (0, 1), got {outlier_threshold}") + + # Compute QC metrics + img_node = sdata.images[image_key] + img_da = get_element_data(img_node, scale, "image", image_key) + img_yxc = _ensure_dim_order(img_da, "yxc") + gray = _to_gray_dask_yx(img_yxc) + H, W = int(gray.shape[0]), int(gray.shape[1]) + + tg = TileGrid(H, W, tile_size) + obs_names = tg.names() + + logger.info("Quantifying image quality.") + logger.info(f"- Input image (x, y): ({W}, {H})") + logger.info(f"- Tile size (x, y): ({tg.tx}, {tg.ty})") + logger.info(f"- Number of tiles (n_x, n_y): ({tg.tiles_x}, {tg.tiles_y})") + + # Group metrics by InputKind + groups: dict[InputKind, list[QCMetric]] = defaultdict(list) + for m in metrics: + kind, _fn = get_metric_info(m) + groups[kind].append(m) + + out_chunks = ((1,) * tg.tiles_y, (1,) * tg.tiles_x) + + # Prepare inputs lazily (only for needed kinds) + prepared_inputs: dict[InputKind, da.Array] = {} + + _tissue_binary_da: da.Array | None = None + if InputKind.MASK in groups or detect_tissue: + mask_key_resolved = resolve_tissue_mask(sdata, image_key, scale, tissue_mask_key) + raw_mask = get_mask_dask(sdata, mask_key_resolved, scale) + _tissue_binary_da = (raw_mask > 0).astype(np.float32).rechunk((tg.ty, tg.tx)) + + if InputKind.GRAYSCALE in groups: + prepared_inputs[InputKind.GRAYSCALE] = gray.rechunk((tg.ty, tg.tx)) + + if InputKind.RGB in groups: + src_dtype = img_yxc.data.dtype + rgb_arr = img_yxc.data[..., :3].astype(np.float32, copy=False) + # Normalize integer images to [0, 1] + if np.issubdtype(src_dtype, np.integer): + rgb_arr = rgb_arr / float(np.iinfo(src_dtype).max) + prepared_inputs[InputKind.RGB] = rgb_arr.rechunk((tg.ty, tg.tx, 3)) + + if InputKind.MASK in groups and _tissue_binary_da is not None: + prepared_inputs[InputKind.MASK] = _tissue_binary_da + + # Build all dask graphs lazily + delayed_scores: dict[str, da.Array] = {} + hed_metric_indices = { + QCMetric.HEMATOXYLIN_MEAN: 0, + QCMetric.HEMATOXYLIN_STD: 1, + QCMetric.EOSIN_MEAN: 2, + QCMetric.EOSIN_STD: 3, + QCMetric.HE_RATIO: 4, + } + hed_scores: da.Array | None = None + + for m in metrics: + if m in hed_metric_indices: + if hed_scores is None: + hed_scores = da.map_blocks( + hed_metrics, + prepared_inputs[InputKind.RGB], + dtype=np.float32, + chunks=out_chunks + ((5,),), + drop_axis=2, + new_axis=2, + ) + delayed_scores[m.value] = hed_scores[..., hed_metric_indices[m]] + logger.info(f"- Calculating metric: '{m.value}'") + continue + + kind, metric_func = get_metric_info(m) + source = prepared_inputs[kind] + + if kind == InputKind.RGB: + delayed_scores[m.value] = da.map_blocks( + metric_func, source, dtype=np.float32, chunks=out_chunks, drop_axis=2 + ) + else: + delayed_scores[m.value] = da.map_blocks(metric_func, source, dtype=np.float32, chunks=out_chunks) + logger.info(f"- Calculating metric: '{m.value}'") + + # Single dask.compute() across all metric types + if progress: + with ProgressBar(): + results = dask.compute(*delayed_scores.values()) + else: + results = dask.compute(*delayed_scores.values()) + all_scores: dict[str, np.ndarray] = dict(zip(delayed_scores.keys(), results, strict=True)) + + # Build AnnData + metric_names = [m.value for m in metrics] + first = next(iter(all_scores.values())) + cents, _ = tg.centroids_and_polygons() + n_tiles = first.size + X = np.column_stack([all_scores[n].ravel() for n in metric_names]) + var_names = [f"qc_{n}" for n in metric_names] + + adata = AnnData(X=X) + adata.var_names = var_names + adata.obs_names = obs_names + adata.obs["centroid_y"] = cents[:, 0] + adata.obs["centroid_x"] = cents[:, 1] + adata.obsm["spatial"] = cents + + tissue = np.zeros(n_tiles, dtype=bool) + back = ~tissue + outlier_labels = np.ones(n_tiles, dtype=int) + unfocus_scores: np.ndarray | None = None + + if detect_outliers: + if detect_tissue: + tissue, back = _classify_tiles_by_tissue( + tg, + sdata=sdata, + image_key=image_key, + scale=scale, + tissue_mask_key=tissue_mask_key, + binary_mask_da=_tissue_binary_da, + ) + logger.info(f"- Classified tiles: background: {back.sum()}, tissue: {tissue.sum()}.") + + if detect_tissue and tissue.any(): + tissue_scores = _compute_unfocus_scores(X[tissue], var_names) + unfocus_scores = np.full(n_tiles, np.nan, dtype=np.float32) + unfocus_scores[tissue] = tissue_scores + outlier_labels = np.ones(n_tiles, dtype=int) + outlier_labels[tissue] = np.where(tissue_scores >= 1 - outlier_threshold, -1, 1) + else: + all_scores_arr = _compute_unfocus_scores(X, var_names) + unfocus_scores = all_scores_arr + outlier_labels = np.where(all_scores_arr >= 1 - outlier_threshold, -1, 1) + + adata.obs["qc_outlier"] = pd.Categorical((outlier_labels == -1).astype(str), categories=["False", "True"]) + if detect_tissue: + adata.obs["is_tissue"] = pd.Categorical(tissue.astype(str), categories=["False", "True"]) + adata.obs["is_background"] = pd.Categorical(back.astype(str), categories=["False", "True"]) + adata.obs["unfocus_score"] = unfocus_scores + + logger.info(f"- Detected {int((outlier_labels == -1).sum())} outlier tiles.") + + adata.uns["qc_image"] = { + "metrics": list(all_scores.keys()), + "tile_size_y": tg.ty, + "tile_size_x": tg.tx, + "image_height": H, + "image_width": W, + "n_tiles_y": tg.tiles_y, + "n_tiles_x": tg.tiles_x, + "image_key": image_key, + "scale": scale, + "is_hne": is_hne, + "detect_tissue": detect_tissue, + "outlier_threshold": outlier_threshold, + "n_tissue_tiles": int(tissue.sum()), + "n_background_tiles": int(back.sum()), + "n_outlier_tiles": int((outlier_labels == -1).sum()), + } + + table_key = f"qc_img_{image_key}" + shapes_key = f"qc_img_{image_key}_grid" + + # Build shapes first (need the index for tile_id linkage) + save_tile_grid_to_shapes(sdata, tg, shapes_key, copy_transforms_from_key=image_key) + + # Set spatialdata linkage on adata BEFORE TableModel.parse + adata.obs["grid_name"] = pd.Categorical([shapes_key] * len(adata)) + adata.obs["tile_id"] = sdata.shapes[shapes_key].index + adata.uns["spatialdata_attrs"] = { + "region": shapes_key, + "region_key": "grid_name", + "instance_key": "tile_id", + } + + sdata.tables[table_key] = TableModel.parse(adata) + logger.info(f"- Saved QC scores as 'sdata.tables[\"{table_key}\"]'") + + if preview and detect_outliers and "qc_outlier" in adata.obs.columns: + try: + ( + sdata.pl.render_images(image_key) + .pl.render_shapes( + shapes_key, + color="qc_outlier", + groups="True", + palette="red", + fill_alpha=0.5, + table_name=table_key, + ) + .pl.show() + ) + except (AttributeError, KeyError, ValueError) as e: + logger.warning(f"Could not generate preview plot: {e}") + + +_LUMINANCE_WEIGHTS = da.from_array(np.array([0.2126, 0.7152, 0.0722], dtype=np.float32), chunks=(3,)) + + +def _to_gray_dask_yx(img_yxc: xr.DataArray) -> da.Array: + """Convert multi-channel image to grayscale using ITU-R BT.709 luminance weights.""" + arr = img_yxc.data + if arr.ndim != 3: + raise ValueError(f"Expected image with shape `(y, x, c)`, found `{arr.shape}`.") + c = arr.shape[2] + if c == 1: + return arr[..., 0].astype(np.float32, copy=False) + rgb = arr[..., :3].astype(np.float32, copy=False) + gray = da.tensordot(rgb, _LUMINANCE_WEIGHTS, axes=([2], [0])) + return gray.astype(np.float32, copy=False) + + +def _classify_tiles_by_tissue( + tg: TileGrid, + *, + sdata: SpatialData, + image_key: str, + scale: str = "scale0", + tissue_mask_key: str | None = None, + binary_mask_da: da.Array | None = None, +) -> tuple[np.ndarray, np.ndarray]: + """Classify tiles as tissue or background based on a binary mask. + + Returns + ------- + tissue, background + Boolean arrays of shape ``(n_tiles,)``. + """ + if binary_mask_da is None: + mask_key_resolved = resolve_tissue_mask(sdata, image_key, scale, tissue_mask_key) + raw_mask = get_mask_dask(sdata, mask_key_resolved, scale) + binary_mask_da = (raw_mask > 0).astype(np.float32).rechunk((tg.ty, tg.tx)) + + H, W = binary_mask_da.shape + tiles_y = (H + tg.ty - 1) // tg.ty + tiles_x = (W + tg.tx - 1) // tg.tx + + def _mean_block(block: np.ndarray) -> np.ndarray: + return np.array([[float(block.mean())]], dtype=np.float32) + + frac_da = da.map_blocks( + _mean_block, + binary_mask_da, + dtype=np.float32, + chunks=((1,) * tiles_y, (1,) * tiles_x), + ) + n_tiles = tg.tiles_y * tg.tiles_x + frac = frac_da.compute().ravel()[:n_tiles] + + tissue = frac > 0.5 + return tissue, ~tissue + + +def _compute_unfocus_scores(X: np.ndarray, var_names: list[str]) -> np.ndarray: + """Compute per-tile unfocus scores using within-group percentile ranks. + + For each sharpness metric, every tile is ranked among its peers (the tiles + in ``X``) and assigned a percentile in [0, 1] where 0 = lowest value = + worst quality and 1 = highest. The final ``unfocus_score`` for a tile is + ``1 - min_rank`` across all *sharpness* metrics, so a tile that scores + poorly on *any* sharpness axis gets a high unfocus score. + + Only gradient-based sharpness metrics (tenengrad, var_of_laplacian) + contribute to the score. Other metrics like entropy, wavelet energy, + or FFT energy correlate more with tissue structure than with actual + optical focus and are therefore excluded. + + Parameters + ---------- + X + Score matrix of shape ``(n_tiles, n_metrics)``. + var_names + Column names matching ``X`` (``qc_`` prefix expected). + + Returns + ------- + np.ndarray of shape ``(n_tiles,)`` with values in [0, 1]. + """ + from scipy.stats import rankdata + + _SHARPNESS_KEYWORDS = {"tenengrad", "laplacian"} + + sharpness_cols = [i for i, name in enumerate(var_names) if any(kw in name.lower() for kw in _SHARPNESS_KEYWORDS)] + if not sharpness_cols: + sharpness_cols = list(range(X.shape[1])) + + n = X.shape[0] + if n <= 1: + return np.zeros(n, dtype=np.float32) + + # Percentile rank per metric: 0 = worst, 1 = best + ranks = np.column_stack([(rankdata(X[:, col], method="average") - 1) / (n - 1) for col in sharpness_cols]) + + # Worst (minimum) rank across sharpness metrics governs the score + min_rank = ranks.min(axis=1) + + # Invert: high unfocus_score = low quality + return (1.0 - min_rank).astype(np.float32) diff --git a/src/squidpy/experimental/im/_qc_metrics.py b/src/squidpy/experimental/im/_qc_metrics.py new file mode 100644 index 000000000..05c6567bc --- /dev/null +++ b/src/squidpy/experimental/im/_qc_metrics.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from collections.abc import Callable +from enum import StrEnum + +import numpy as np + +from squidpy.experimental.im._intensity_metrics import ( + brightness_mean, + brightness_std, + entropy, + eosin_mean, + eosin_std, + fold_fraction, + he_ratio, + hematoxylin_mean, + hematoxylin_std, + tissue_fraction, +) +from squidpy.experimental.im._sharpness_metrics import ( + fft_high_freq_energy, + haar_wavelet_energy, + laplacian_variance, + pop_variance, + tenengrad_mean, +) + +MetricFn = Callable[[np.ndarray], np.ndarray] + + +class InputKind(StrEnum): + GRAYSCALE = "grayscale" # (ty, tx) float32 + RGB = "rgb" # (ty, tx, 3) float32 in [0,1] + MASK = "mask" # (ty, tx) binary float32 + + +class QCMetric(StrEnum): + # Sharpness (grayscale input) + TENENGRAD = "tenengrad" + VAR_OF_LAPLACIAN = "var_of_laplacian" + VARIANCE = "variance" + FFT_HIGH_FREQ_ENERGY = "fft_high_freq_energy" + HAAR_WAVELET_ENERGY = "haar_wavelet_energy" + # Intensity (grayscale input) + BRIGHTNESS_MEAN = "brightness_mean" + BRIGHTNESS_STD = "brightness_std" + ENTROPY = "entropy" + # Staining (RGB input, H&E only) + HEMATOXYLIN_MEAN = "hematoxylin_mean" + HEMATOXYLIN_STD = "hematoxylin_std" + EOSIN_MEAN = "eosin_mean" + EOSIN_STD = "eosin_std" + HE_RATIO = "he_ratio" + # Artifacts (RGB input, H&E only) + FOLD_FRACTION = "fold_fraction" + # Tissue coverage (mask input) + TISSUE_FRACTION = "tissue_fraction" + + +_HNE_METRICS: set[QCMetric] = { + QCMetric.HEMATOXYLIN_MEAN, + QCMetric.HEMATOXYLIN_STD, + QCMetric.EOSIN_MEAN, + QCMetric.EOSIN_STD, + QCMetric.HE_RATIO, + QCMetric.FOLD_FRACTION, +} + + +# --- Registry --- + +_METRIC_REGISTRY: dict[QCMetric, tuple[InputKind, MetricFn]] = { + # Sharpness (grayscale) + QCMetric.TENENGRAD: (InputKind.GRAYSCALE, tenengrad_mean), + QCMetric.VAR_OF_LAPLACIAN: (InputKind.GRAYSCALE, laplacian_variance), + QCMetric.VARIANCE: (InputKind.GRAYSCALE, pop_variance), + QCMetric.FFT_HIGH_FREQ_ENERGY: (InputKind.GRAYSCALE, fft_high_freq_energy), + QCMetric.HAAR_WAVELET_ENERGY: (InputKind.GRAYSCALE, haar_wavelet_energy), + # Intensity (grayscale) + QCMetric.BRIGHTNESS_MEAN: (InputKind.GRAYSCALE, brightness_mean), + QCMetric.BRIGHTNESS_STD: (InputKind.GRAYSCALE, brightness_std), + QCMetric.ENTROPY: (InputKind.GRAYSCALE, entropy), + # Staining (RGB, H&E only) + QCMetric.HEMATOXYLIN_MEAN: (InputKind.RGB, hematoxylin_mean), + QCMetric.HEMATOXYLIN_STD: (InputKind.RGB, hematoxylin_std), + QCMetric.EOSIN_MEAN: (InputKind.RGB, eosin_mean), + QCMetric.EOSIN_STD: (InputKind.RGB, eosin_std), + QCMetric.HE_RATIO: (InputKind.RGB, he_ratio), + # Artifacts (RGB, H&E only) + QCMetric.FOLD_FRACTION: (InputKind.RGB, fold_fraction), + # Tissue coverage (mask) + QCMetric.TISSUE_FRACTION: (InputKind.MASK, tissue_fraction), +} + + +def get_metric_info(metric: QCMetric) -> tuple[InputKind, MetricFn]: + """Look up the input kind and callable for a QCMetric.""" + return _METRIC_REGISTRY[metric] diff --git a/src/squidpy/experimental/im/_sharpness_metrics.py b/src/squidpy/experimental/im/_sharpness_metrics.py new file mode 100644 index 000000000..f48f2aeaf --- /dev/null +++ b/src/squidpy/experimental/im/_sharpness_metrics.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import numpy as np +from scipy.fft import fft2, fftfreq +from skimage.filters import laplace, sobel_h, sobel_v + + +def to_f32_2d(x: np.ndarray) -> np.ndarray: + if x.ndim != 2: + raise ValueError("block must be 2D") + return np.ascontiguousarray(x.astype(np.float32, copy=False)) + + +def tenengrad_mean(block: np.ndarray) -> np.ndarray: + """Mean Tenengrad energy (sum of squared Sobel gradients).""" + b = to_f32_2d(block) + energy = sobel_h(b) ** 2 + sobel_v(b) ** 2 + return np.array([[float(energy.mean())]], dtype=np.float32) + + +def laplacian_variance(block: np.ndarray) -> np.ndarray: + """Population variance of Laplacian response.""" + b = to_f32_2d(block) + lap = laplace(b) + var_val = float(np.var(lap)) + return np.array([[max(var_val, 0.0)]], dtype=np.float32) + + +def pop_variance(block: np.ndarray) -> np.ndarray: + """Population variance of pixel intensities.""" + b = to_f32_2d(block) + return np.array([[float(np.var(b))]], dtype=np.float32) + + +def fft_high_freq_mask(h: int, w: int) -> np.ndarray: + """Frequency-domain mask for high-frequency energy (cached per tile shape).""" + fy = fftfreq(h) + fx = fftfreq(w) + ry, rx = np.meshgrid(fy, fx, indexing="ij") + return np.hypot(ry, rx) > 0.1 + + +def fft_high_freq_energy(block: np.ndarray) -> np.ndarray: + x = to_f32_2d(block).astype(np.float64, copy=False) + m = float(x.mean()) + s = float(x.std()) + x = (x - m) / s if s > 0.0 else (x - m) + + F = fft2(x) + mag2 = (F.real * F.real) + (F.imag * F.imag) + + mask = fft_high_freq_mask(x.shape[0], x.shape[1]) + + total = float(mag2.sum()) + if not np.isfinite(total) or total <= 1e-12: + ratio = 0.0 + else: + hi = float(mag2[mask].sum()) + ratio = hi / total if np.isfinite(hi) else 0.0 + ratio = np.clip(ratio, 0.0, 1.0) + return np.array([[ratio]], dtype=np.float32) + + +def haar_wavelet_energy(block: np.ndarray) -> np.ndarray: + """Detail-band (LH+HL+HH) energy ratio of a single-level Haar transform.""" + x = to_f32_2d(block).astype(np.float64, copy=False) + m = float(x.mean()) + s = float(x.std()) + x = (x - m) / s if s > 0.0 else (x - m) + + h, w = x.shape + if h % 2 == 1: + x = np.vstack([x, x[-1:, :]]) + h += 1 + if w % 2 == 1: + x = np.hstack([x, x[:, -1:]]) + w += 1 + + cA_h = (x[::2, :] + x[1::2, :]) / 2.0 + cH_h = (x[::2, :] - x[1::2, :]) / 2.0 + + cA = (cA_h[:, ::2] + cA_h[:, 1::2]) / 2.0 # LL + cH = (cA_h[:, ::2] - cA_h[:, 1::2]) / 2.0 # LH + cV = (cH_h[:, ::2] + cH_h[:, 1::2]) / 2.0 # HL + cD = (cH_h[:, ::2] - cH_h[:, 1::2]) / 2.0 # HH + + total = float((cA * cA).sum() + (cH * cH).sum() + (cV * cV).sum() + (cD * cD).sum()) + if not np.isfinite(total) or total <= 1e-12: + ratio = 0.0 + else: + detail = float((cH * cH).sum() + (cV * cV).sum() + (cD * cD).sum()) + ratio = detail / total if np.isfinite(detail) else 0.0 + ratio = np.clip(ratio, 0.0, 1.0) + return np.array([[ratio]], dtype=np.float32) diff --git a/src/squidpy/experimental/im/_utils.py b/src/squidpy/experimental/im/_utils.py index 8ce954e04..11467da49 100644 --- a/src/squidpy/experimental/im/_utils.py +++ b/src/squidpy/experimental/im/_utils.py @@ -2,11 +2,102 @@ from typing import Any, Literal +import dask.array as da +import geopandas as gpd +import numpy as np import xarray as xr +from shapely import box +from spatialdata import SpatialData from spatialdata._logging import logger +from spatialdata.models import ShapesModel +from spatialdata.transformations import get_transformation, set_transformation -def _get_element_data( +class TileGrid: + """Immutable tile grid definition with cached bounds and centroids.""" + + def __init__( + self, + H: int, + W: int, + tile_size: Literal["auto"] | tuple[int, int] = "auto", + target_tiles: int = 100, + offset_y: int = 0, + offset_x: int = 0, + ): + self.H = int(H) + self.W = int(W) + if tile_size == "auto": + size = max(min(self.H // target_tiles, self.W // target_tiles), 100) + self.ty = int(size) + self.tx = int(size) + else: + self.ty = int(tile_size[0]) + self.tx = int(tile_size[1]) + self.offset_y = offset_y + self.offset_x = offset_x + # Calculate number of tiles needed to cover entire image, accounting for offset + grid_start_y = min(0, self.offset_y) + grid_start_x = min(0, self.offset_x) + total_h_needed = self.H - grid_start_y + total_w_needed = self.W - grid_start_x + self.tiles_y = (total_h_needed + self.ty - 1) // self.ty + self.tiles_x = (total_w_needed + self.tx - 1) // self.tx + # Cache immutable derived values (vectorized) + iy = np.repeat(np.arange(self.tiles_y), self.tiles_x) + ix = np.tile(np.arange(self.tiles_x), self.tiles_y) + self._indices = np.column_stack([iy, ix]) + self._names = [f"tile_x{x}_y{y}" for y, x in zip(iy, ix, strict=True)] + self._bounds = self._compute_bounds(iy, ix) + self._centroids, self._polys = self._compute_centroids_and_polygons() + + def indices(self) -> np.ndarray: + return self._indices + + def names(self) -> list[str]: + return self._names + + def bounds(self) -> np.ndarray: + return self._bounds + + def _compute_bounds(self, iy: np.ndarray, ix: np.ndarray) -> np.ndarray: + y0 = iy * self.ty + self.offset_y + x0 = ix * self.tx + self.offset_x + y1 = (iy + 1) * self.ty + self.offset_y + x1 = (ix + 1) * self.tx + self.offset_x + # Last row/column extends to image edge + y1[iy == self.tiles_y - 1] = self.H + x1[ix == self.tiles_x - 1] = self.W + # Clamp to image dimensions + y0 = np.clip(y0, 0, self.H) + x0 = np.clip(x0, 0, self.W) + y1 = np.clip(y1, 0, self.H) + x1 = np.clip(x1, 0, self.W) + return np.column_stack([y0, x0, y1, x1]).astype(int) + + def centroids_and_polygons(self) -> tuple[np.ndarray, list]: + return self._centroids, self._polys + + def _compute_centroids_and_polygons(self) -> tuple[np.ndarray, list]: + y0, x0, y1, x1 = self._bounds[:, 0], self._bounds[:, 1], self._bounds[:, 2], self._bounds[:, 3] + centroids = np.column_stack([(y0 + y1) / 2.0, (x0 + x1) / 2.0]) + polys = list(box(x0, y0, x1, y1)) + return centroids, polys + + def rechunk_and_pad(self, arr_yx: da.Array) -> da.Array: + if arr_yx.ndim != 2: + raise ValueError("Expected a 2D array shaped (y, x).") + pad_y = self.tiles_y * self.ty - int(arr_yx.shape[0]) + pad_x = self.tiles_x * self.tx - int(arr_yx.shape[1]) + a = arr_yx.rechunk((self.ty, self.tx)) + return da.pad(a, ((0, pad_y), (0, pad_x)), mode="edge") if (pad_y > 0 or pad_x > 0) else a + + def coarsen(self, arr_yx: da.Array, reduce: Literal["mean", "sum"] = "mean") -> da.Array: + reducer = np.mean if reduce == "mean" else np.sum + return da.coarsen(reducer, arr_yx, {0: self.ty, 1: self.tx}, trim_excess=False) + + +def get_element_data( element_node: Any, scale: str | Literal["auto"], element_type: str = "element", @@ -58,7 +149,7 @@ def _idx(k: str) -> int: return data -def _flatten_channels( +def flatten_channels( img: xr.DataArray, channel_format: Literal["infer", "rgb", "rgba", "multichannel"] = "infer", ) -> xr.DataArray: @@ -129,3 +220,107 @@ def _flatten_channels( raise ValueError( f"Invalid channel_format: {channel_format}. Must be one of 'infer', 'rgb', 'rgba', 'multichannel'." ) + + +def get_mask_dask(sdata: SpatialData, mask_key: str, scale: str) -> da.Array: + """Extract mask as a lazy dask array from ``sdata.labels``.""" + if mask_key not in sdata.labels: + raise KeyError(f"Mask key '{mask_key}' not found in sdata.labels") + label_node = sdata.labels[mask_key] + mask_xr = get_element_data(label_node, scale, "label", mask_key) + + arr = mask_xr.data if hasattr(mask_xr, "data") else mask_xr + if not isinstance(arr, da.Array): + arr = da.from_array(np.asarray(arr)) + + if arr.ndim > 2: + arr = arr.squeeze() + if arr.ndim != 2: + raise ValueError(f"Expected 2D mask with shape (y, x), got shape {arr.shape}") + return arr + + +def get_mask_materialized(sdata: SpatialData, mask_key: str, scale: str) -> np.ndarray: + """Extract a 2D mask array from ``sdata.labels`` at the requested scale (materialized).""" + arr = get_mask_dask(sdata, mask_key, scale) + return np.asarray(arr.compute()) + + +def resolve_tissue_mask( + sdata: SpatialData, + image_key: str, + scale: str, + tissue_mask_key: str | None = None, +) -> str: + """Return the key of a tissue mask in ``sdata.labels``, creating one if needed. + + If *tissue_mask_key* is given and exists, it is returned as-is. + Otherwise falls back to ``f"{image_key}_tissue"``, running ``detect_tissue`` + to create it when missing. + + Raises + ------ + KeyError + If *tissue_mask_key* is given but not found in ``sdata.labels``. + Exception + Any exception raised by ``detect_tissue`` if auto-creation fails. + Callers needing graceful fallback should wrap this call in try/except. + """ + if tissue_mask_key is not None: + if tissue_mask_key not in sdata.labels: + raise KeyError(f"Tissue mask key '{tissue_mask_key}' not found in sdata.labels") + return tissue_mask_key + + mask_key = f"{image_key}_tissue" + if mask_key not in sdata.labels: + from squidpy.experimental.im._detect_tissue import detect_tissue + + detect_tissue(sdata=sdata, image_key=image_key, scale=scale, inplace=True, new_labels_key=mask_key) + logger.info(f"Saved tissue mask as 'sdata.labels[\"{mask_key}\"]'") + return mask_key + + +def save_tile_grid_to_shapes( + sdata: SpatialData, + tg: TileGrid, + shapes_key: str, + *, + copy_transforms_from_key: str | None = None, +) -> None: + """Save a TileGrid to ``sdata.shapes`` as a GeoDataFrame. + + Parameters + ---------- + sdata + SpatialData object. + tg + TileGrid whose bounds/centroids are persisted. + shapes_key + Key under which to store the shapes. + copy_transforms_from_key + If given, copy the transformations from ``sdata.images[copy_transforms_from_key]`` + onto the new shapes element. + """ + tile_indices = tg.indices() + pixel_bounds = tg.bounds() + _, polys = tg.centroids_and_polygons() + + tile_gdf = gpd.GeoDataFrame( + { + "tile_id": tg.names(), + "tile_y": tile_indices[:, 0], + "tile_x": tile_indices[:, 1], + "pixel_y0": pixel_bounds[:, 0], + "pixel_x0": pixel_bounds[:, 1], + "pixel_y1": pixel_bounds[:, 2], + "pixel_x1": pixel_bounds[:, 3], + "geometry": polys, + }, + geometry="geometry", + ) + + sdata.shapes[shapes_key] = ShapesModel.parse(tile_gdf) + if copy_transforms_from_key is not None: + transformations = get_transformation(sdata.images[copy_transforms_from_key], get_all=True) + set_transformation(sdata.shapes[shapes_key], transformations, set_all=True) + logger.info(f"- Saved tile grid as 'sdata.shapes[\"{shapes_key}\"]'") diff --git a/src/squidpy/experimental/pl/__init__.py b/src/squidpy/experimental/pl/__init__.py index 4d21ee850..cdb8a56d1 100644 --- a/src/squidpy/experimental/pl/__init__.py +++ b/src/squidpy/experimental/pl/__init__.py @@ -1,3 +1,5 @@ from __future__ import annotations -__all__ = [] +from ._qc_image import qc_image + +__all__ = ["qc_image"] diff --git a/src/squidpy/experimental/pl/_qc_image.py b/src/squidpy/experimental/pl/_qc_image.py new file mode 100644 index 000000000..bf369b753 --- /dev/null +++ b/src/squidpy/experimental/pl/_qc_image.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +from typing import Any + +import matplotlib.pyplot as plt +import numpy as np +from scipy.stats import gaussian_kde +from spatialdata import SpatialData +from spatialdata._logging import logger as logg + +from squidpy.experimental.im._qc_metrics import QCMetric + + +def qc_image( + sdata: SpatialData, + image_key: str, + metrics: QCMetric | list[QCMetric] | None = None, + figsize: tuple[int, int] | None = None, + return_ax: bool = False, + **kwargs: Any, +) -> np.ndarray | None: + """ + Plot a summary view of QC metrics from qc_image results. + + Automatically scans adata.uns for calculated metrics and plots the values. + Creates a multi-panel plot: one panel per calculated metric. + Each panel shows: spatial view, KDE plot, and statistics. + + Parameters + ---------- + sdata : SpatialData + SpatialData object containing QC results. + image_key : str + Image key used in qc_image function. + metrics : QCMetric or list of QCMetric, optional + Specific metrics to plot. If None, plots all calculated metrics. + figsize : tuple, optional + Figure size (width, height). Auto-calculated if None. + return_ax : bool + Whether to return the axes array. Default is False. + **kwargs + Additional arguments passed to render_shapes(). + + Returns + ------- + axes : numpy.ndarray of matplotlib.axes.Axes or None + The axes array if return_ax=True, otherwise None. + """ + table_key = f"qc_img_{image_key}" + shapes_key = f"qc_img_{image_key}_grid" + + if table_key not in sdata.tables: + raise ValueError(f"No QC data found for image '{image_key}'. Run sq.exp.im.qc_image() first.") + + adata = sdata.tables[table_key] + + if "qc_image" not in adata.uns: + raise ValueError("No qc_image metadata found. Run sq.exp.im.qc_image() first.") + + calculated_metrics = adata.uns["qc_image"]["metrics"] + + if not calculated_metrics: + raise ValueError("No QC metrics found in metadata.") + + if metrics is not None: + metrics_list = metrics if isinstance(metrics, list) else [metrics] + metrics_to_plot = [] + for metric in metrics_list: + metric_name = metric.value if isinstance(metric, QCMetric) else metric + if metric_name not in calculated_metrics: + raise ValueError(f"Metric '{metric_name}' not found. Available: {calculated_metrics}") + metrics_to_plot.append(metric_name) + else: + metrics_to_plot = calculated_metrics + + logg.info(f"Plotting {len(metrics_to_plot)} QC metrics: {metrics_to_plot}") + + n_metrics = len(metrics_to_plot) + ncols = 3 + nrows = n_metrics + + if figsize is None: + figsize = (12, 4 * nrows) + + _fig, axes = plt.subplots(nrows, ncols, figsize=figsize) + + if nrows == 1: + axes = axes.reshape(1, -1) + if ncols == 1: + axes = axes.reshape(-1, 1) + + var_name_to_idx = {name: idx for idx, name in enumerate(adata.var_names)} + + for i, metric_name in enumerate(metrics_to_plot): + var_name = f"qc_{metric_name}" + if var_name not in var_name_to_idx: + logg.warning(f"Variable '{var_name}' not found in adata.var_names. Skipping.") + continue + + raw_values = adata.X[:, var_name_to_idx[var_name]] + + ax_spatial = axes[i, 0] + ax_hist = axes[i, 1] + ax_stats = axes[i, 2] + + # Panel 1: Spatial plot + try: + ( + sdata.pl.render_shapes(shapes_key, color=var_name, table_name=table_key, **kwargs).pl.show( + ax=ax_spatial, title=f"{metric_name.replace('_', ' ').title()}" + ) + ) + except (ValueError, KeyError, AttributeError) as e: + logg.warning(f"Error plotting spatial view for {metric_name}: {e}") + ax_spatial.text( + 0.5, 0.5, f"Error plotting\n{metric_name}", ha="center", va="center", transform=ax_spatial.transAxes + ) + ax_spatial.set_title(f"{metric_name.replace('_', ' ').title()}") + + # Panel 2: KDE plot + x_min, x_max = float(np.min(raw_values)), float(np.max(raw_values)) + + if x_min < x_max: + x_range = np.linspace(x_min, x_max, 200) + + if "is_tissue" in adata.obs: + is_tissue = adata.obs["is_tissue"].astype(str) == "True" + tissue_values = raw_values[is_tissue] + background_values = raw_values[~is_tissue] + + if len(background_values) > 1 and np.std(background_values) > 0: + kde_background = gaussian_kde(background_values) + density_background = kde_background(x_range) + ax_hist.plot(x_range, density_background, label="Background", alpha=0.7) + ax_hist.fill_between(x_range, density_background, alpha=0.3) + + if len(tissue_values) > 1 and np.std(tissue_values) > 0: + kde_tissue = gaussian_kde(tissue_values) + density_tissue = kde_tissue(x_range) + ax_hist.plot(x_range, density_tissue, label="Tissue", alpha=0.7) + ax_hist.fill_between(x_range, density_tissue, alpha=0.3) + + ax_hist.legend() + + elif len(raw_values) > 1 and np.std(raw_values) > 0: + kde = gaussian_kde(raw_values) + density = kde(x_range) + ax_hist.plot(x_range, density, alpha=0.7) + ax_hist.fill_between(x_range, density, alpha=0.3) + else: + ax_hist.text( + 0.5, + 0.5, + f"Constant value: {x_min:.4f}", + ha="center", + va="center", + transform=ax_hist.transAxes, + ) + + ax_hist.set_xlabel(f"{metric_name.replace('_', ' ').title()}") + ax_hist.set_ylabel("Density") + ax_hist.set_title("Distribution") + ax_hist.grid(True, alpha=0.3) + + # Panel 3: Statistics + ax_stats.axis("off") + stats_text = ( + f"Raw {metric_name.replace('_', ' ').title()} Statistics:\n" + f"\n" + f"Count: {len(raw_values):,}\n" + f"Mean: {np.mean(raw_values):.4f}\n" + f"Std: {np.std(raw_values):.4f}\n" + f"Min: {np.min(raw_values):.4f}\n" + f"Max: {np.max(raw_values):.4f}\n" + f"\n" + f"Percentiles:\n" + f"5%: {np.percentile(raw_values, 5):.4f}\n" + f"25%: {np.percentile(raw_values, 25):.4f}\n" + f"50%: {np.percentile(raw_values, 50):.4f}\n" + f"75%: {np.percentile(raw_values, 75):.4f}\n" + f"95%: {np.percentile(raw_values, 95):.4f}\n" + f"\n" + f"Non-zero: {np.count_nonzero(raw_values):,}\n" + f"Zero: {np.sum(raw_values == 0):,}" + ) + + ax_stats.text( + 0.05, + 0.95, + stats_text, + transform=ax_stats.transAxes, + fontsize=9, + verticalalignment="top", + fontfamily="monospace", + ) + + plt.tight_layout() + + return axes if return_ax else None diff --git a/tests/_images/QCImage_calc_qc_image_hne.png b/tests/_images/QCImage_calc_qc_image_hne.png new file mode 100644 index 0000000000000000000000000000000000000000..c02d403082d6f656fb7ebf0c8ce609eba7424ea3 GIT binary patch literal 21230 zcmb4~Q*(2i^-1qxXRWH@OR@drY z{q6nzc7&3GBoaI>JQx@llC+eV%D*-5zXAL0-yAh#!SHY4aTV8cRdq0TbvJT01Cuv$ zb+mPGwY4%P{%PjyV&!1Z%E-dZ$VE?V>FVm}!o$R5_kTMW9h@zg`gGl<|J?=7QA*nd z4D1`#e*;`zZFK|;OhrdpOjyk$>#_^3%GOGo@x97(Nw4`A>*Hz3Z>!$0NBvr%jEqbr=2k7{X^XLpnD-ji`Batt0mko zzN;r+o9c>2#V4!Y8l>Od)2ICE)jH?8S6(B>zWSBakr0*5IX7t`AoiiP{9cu=+YADC%)D8`ZHXYd?bl`f`#Wg8lHOne1GGLlE<^Oxp|7&|( z-QV9=0-Z{9l^=wI_r``; zCDJcZzPpT>BFDIO*+bHLMr(!hZgt*ai zm}{ne`uo#E;5pO)HR)|@`(x`lSnwJ0{bT!!9~lKjoNHxe zg=oNzt9?S}0eYsk=Y66FkTm{qVIipd-nH~fiY{ag#dDFZ0OS70lS==wAxrqME4<#p86IB1_O;zuO&OvfFP5lBjoi zIrUkhTH_qyBcbwvyXM9tdc&cI;CMQFAT1h9zs(UGMev<@I)|Tb&Y0ixZ4E{sifYpb zoS8?{=bg5r>eG>bqC($bF;+a3u}@U_O*Am8N8pxvIpNpAQ|#eroM(c8oBg3Ap{Ks& zkD0ypA6M**e%5Y(mK4QbtT&Fhbw}^+&k%I9p8mMh`g|m!_g=l^TqQ^W5k))qzSqWY z`#xy;H~Tba;VXKL+nsrLao-yB#WWszzu5KppJAr0#k~?3w7nf^y(b>F<5&8{i36HT zjZ*pz{mPIvJ`KM9xao1HbxIxDe0bbCvFBS1>e0hX8^uxx5>Ftk{=6JScyRc1zIR{l ze_8$<9~c*<7-_5w4qv)Cu%U#|%05;momrvZAocBojt5<3U-=x*`Dbl-Q`kz3Mh8ru zBK}dE!#!go^7<$hfg+Y|Aw$DR6;{p$GA{p#;&_;Bna90z!CVfEK; z8zRNHQ?Z$m?q9;y%l=l|_{jSK;nFSf+-_UqRKC7sehOLUqPo6}-`#z4nB+pgtb#FT zZ6fnMzo$%|(b-z!L9u-BR{pMX-MzXR-ZaBAZoY<3BgbsC0pBxq0ts`MdE_XDy|!n# z9n@H~oZvoTv*bMqnOw5gIh!F%`(?28>~*1eGNXRX>|nX{wd6fDzB*RItUHS<7y8z)66LTH`C$AwnjZ|SscLSfI+QtUVwJ*_cUQsR%$2P&# zskjo>L8r)p;0RdcxSm#CwX1e#u#uMfE;r&J$2!4OAtRm)Bh0%n@HsYd>~E`U0%En> z{mX~GP3uS(f04ukmbj0wkxIiY%6%~7272rqh$58;@uPR!VzVGanL)$p3R*5Lb*;R# zwd&au0(vXMDHGm_!|U5bW88N<1={p0isxw0IeM%_Mb($0XjLCwEYmsL$u_&)W#<~} znSyutF_TVL=MR@j`QQ^f3;nh{!RPIk&2}u_Q(1`^_O$7)9d6=k-0$M)4|dnK#|<020?#Aqgyfif|O;x+`-G#DZPvMcN< z;jqS?62lJeO`9rdM=xCuf<rWz@0qhGoEI#VAvWs8$pCQ7bSiE6 z!Lv6*2ddt)P1+j7BL_B(;td;x@MTb9jU#6gsMtY$Hr-j4eZ^~Fs zp6_yBx(*XXQFOh?}^mpO4ye(I7=cKPWF{N5_~snlIQ2Cc$+lUa8f;c4rh>8x6Lw_I

O6WTvE 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 UOPaB~rzdf=)bvzqlS*ESY3yPSB5&;GWasE+ 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 IlrazeAIscLn*aa+ literal 0 HcmV?d00001 diff --git a/tests/_images/QCImage_plot_qc_image.png b/tests/_images/QCImage_plot_qc_image.png new file mode 100644 index 0000000000000000000000000000000000000000..d896a4b705a02c2b6c57fa954cde2e114ee2a514 GIT binary patch literal 17741 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 literal 0 HcmV?d00001 diff --git a/tests/_images/QCSharpness_calc_qc_sharpness.png b/tests/_images/QCSharpness_calc_qc_sharpness.png deleted file mode 100644 index 46971dccb2873b8a13fbb80cfd22f797f100a88e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19379 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 Sx&-uR9WpbvLe&_0JpLEUu=AP# diff --git a/tests/_images/QCSharpness_plot_qc_sharpness.png b/tests/_images/QCSharpness_plot_qc_sharpness.png deleted file mode 100644 index aa3edb35db560cffcf3653f9583b23beb359a999..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30929 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 b=^T^pg8QqTBvE8|F`A5&f@HC{q2GT4>ebbw diff --git a/tests/experimental/test_qc_image.py b/tests/experimental/test_qc_image.py new file mode 100644 index 000000000..1fd62cfe2 --- /dev/null +++ b/tests/experimental/test_qc_image.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +import copy + +import numpy as np +import pytest +import spatialdata_plot as sdp + +import squidpy as sq +from tests.conftest import PlotTester, PlotTesterMeta + +_ = sdp + +# Large tile size to keep tests fast (fewer tiles = less computation). +_FAST_TILE = (1000, 1000) + + +@pytest.fixture(scope="session") +def _sdata_hne_with_tissue(): + """Load the Visium H&E dataset once and pre-compute the tissue mask. + + The tissue mask is the most expensive setup step (~16 s). Pre-computing + it here and deepcopying per test avoids paying that cost repeatedly. + """ + sdata = sq.datasets.visium_hne_sdata() + sq.experimental.im.detect_tissue(sdata, image_key="hne", scale="scale0", inplace=True, new_labels_key="hne_tissue") + return sdata + + +@pytest.fixture() +def sdata_hne(_sdata_hne_with_tissue): + """Per-test deep copy so each test gets a fresh SpatialData with tissue mask.""" + return copy.deepcopy(_sdata_hne_with_tissue) + + +class TestQCImage(PlotTester, metaclass=PlotTesterMeta): + def test_plot_calc_qc_image_hne(self, sdata_hne): + """Test QC image overlay with a single sharpness metric.""" + sq.experimental.im.qc_image( + sdata_hne, + image_key="hne", + tile_size=_FAST_TILE, + metrics=[sq.experimental.im.QCMetric.TENENGRAD], + progress=False, + ) + + ( + sdata_hne.pl.render_images() + .pl.render_shapes("qc_img_hne_grid", color="qc_outlier", groups="True", palette="red", fill_alpha=1.0) + .pl.show() + ) + + def test_plot_calc_qc_image_not_hne(self, sdata_hne): + """Test QC image overlay with is_hne=False default metrics.""" + sq.experimental.im.qc_image( + sdata_hne, + image_key="hne", + tile_size=_FAST_TILE, + is_hne=False, + metrics=None, + progress=False, + ) + + ( + sdata_hne.pl.render_images() + .pl.render_shapes("qc_img_hne_grid", color="qc_outlier", groups="True", palette="red", fill_alpha=1.0) + .pl.show() + ) + + def test_plot_plot_qc_image(self, sdata_hne): + """Test QC image plotting function.""" + sq.experimental.im.qc_image( + sdata_hne, + image_key="hne", + tile_size=_FAST_TILE, + metrics=[sq.experimental.im.QCMetric.TENENGRAD], + progress=False, + ) + + sq.experimental.pl.qc_image( + sdata_hne, + image_key="hne", + ) + + +def test_qc_image_hne_metric_without_hne_flag(sdata_hne): + """Test that H&E metrics raise ValueError when is_hne=False.""" + with pytest.raises(ValueError, match="H&E-specific metrics"): + sq.experimental.im.qc_image( + sdata_hne, + image_key="hne", + tile_size=_FAST_TILE, + is_hne=False, + metrics=[sq.experimental.im.QCMetric.HEMATOXYLIN_MEAN], + ) + + +def test_qc_image_default_metrics_hne(sdata_hne): + """Test that default H&E metrics produce expected var_names.""" + sq.experimental.im.qc_image( + sdata_hne, + image_key="hne", + tile_size=_FAST_TILE, + metrics=None, + is_hne=True, + progress=False, + ) + adata = sdata_hne.tables["qc_img_hne"] + expected = { + "qc_tenengrad", + "qc_var_of_laplacian", + "qc_entropy", + "qc_brightness_mean", + "qc_hematoxylin_mean", + "qc_eosin_mean", + } + assert set(adata.var_names) == expected + + +def test_qc_image_default_metrics_generic(sdata_hne): + """Test that default generic metrics produce expected var_names.""" + sq.experimental.im.qc_image( + sdata_hne, + image_key="hne", + tile_size=_FAST_TILE, + metrics=None, + is_hne=False, + progress=False, + ) + adata = sdata_hne.tables["qc_img_hne"] + expected = { + "qc_tenengrad", + "qc_var_of_laplacian", + "qc_entropy", + "qc_brightness_mean", + } + assert set(adata.var_names) == expected + + +def test_qc_image_rgb_metric(sdata_hne): + """Test running a single RGB metric.""" + sq.experimental.im.qc_image( + sdata_hne, + image_key="hne", + tile_size=_FAST_TILE, + is_hne=True, + metrics=[sq.experimental.im.QCMetric.HEMATOXYLIN_MEAN], + detect_tissue=False, + detect_outliers=False, + progress=False, + ) + adata = sdata_hne.tables["qc_img_hne"] + assert "qc_hematoxylin_mean" in adata.var_names + + +def test_qc_image_outlier_detection_with_tissue(sdata_hne): + """Test that outlier detection with tissue classification populates expected columns.""" + sq.experimental.im.qc_image( + sdata_hne, + image_key="hne", + tile_size=_FAST_TILE, + metrics=[sq.experimental.im.QCMetric.TENENGRAD], + detect_outliers=True, + detect_tissue=True, + progress=False, + ) + adata = sdata_hne.tables["qc_img_hne"] + assert "qc_outlier" in adata.obs.columns + assert "is_tissue" in adata.obs.columns + assert "is_background" in adata.obs.columns + assert "unfocus_score" in adata.obs.columns + assert set(adata.obs["qc_outlier"].cat.categories) == {"False", "True"} + # At least some tiles should be classified as tissue + assert (adata.obs["is_tissue"] == "True").any() + + +def test_qc_image_outlier_detection_without_tissue(sdata_hne): + """Test outlier detection without tissue classification scores all tiles.""" + sq.experimental.im.qc_image( + sdata_hne, + image_key="hne", + tile_size=_FAST_TILE, + metrics=[sq.experimental.im.QCMetric.TENENGRAD], + detect_outliers=True, + detect_tissue=False, + progress=False, + ) + adata = sdata_hne.tables["qc_img_hne"] + assert "qc_outlier" in adata.obs.columns + assert "unfocus_score" in adata.obs.columns + # Without tissue detection, these columns should not exist + assert "is_tissue" not in adata.obs.columns + assert "is_background" not in adata.obs.columns + # All tiles should have a valid unfocus score (no NaNs) + assert not np.any(np.isnan(adata.obs["unfocus_score"].values)) + + +def test_qc_image_compute_only(sdata_hne): + """Test compute-only mode without outlier detection.""" + sq.experimental.im.qc_image( + sdata_hne, + image_key="hne", + tile_size=_FAST_TILE, + metrics=[sq.experimental.im.QCMetric.TENENGRAD, sq.experimental.im.QCMetric.BRIGHTNESS_MEAN], + detect_outliers=False, + detect_tissue=False, + progress=False, + ) + adata = sdata_hne.tables["qc_img_hne"] + assert set(adata.var_names) == {"qc_tenengrad", "qc_brightness_mean"} + # No outlier-related columns + assert "qc_outlier" not in adata.obs.columns + assert "unfocus_score" not in adata.obs.columns + # Spatial coordinates should still be present + assert "centroid_y" in adata.obs.columns + assert "spatial" in adata.obsm From 1cb6fde0e4719d7e9b29fb53abeb930512a953c4 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Mon, 13 Apr 2026 09:04:03 +0200 Subject: [PATCH 48/54] remove coord_type abstraction --- docs/extensibility.md | 6 ------ src/squidpy/gr/_build.py | 5 ++--- src/squidpy/gr/neighbors.py | 25 ++----------------------- 3 files changed, 4 insertions(+), 32 deletions(-) diff --git a/docs/extensibility.md b/docs/extensibility.md index 798d8c714..5ad3c0b98 100644 --- a/docs/extensibility.md +++ b/docs/extensibility.md @@ -15,7 +15,6 @@ The `squidpy.gr.neighbors` module exposes two builder base classes: | Base class | Method / property | Required | Purpose | |---|---|---|---| -| {class}`~squidpy.gr.neighbors.GraphBuilder` | {attr}`~squidpy.gr.neighbors.GraphBuilder.coord_type` | yes | Return the {class}`~squidpy._constants._constants.CoordType` this builder supports. | | {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.build_graph` | yes | Construct and return ``(adj, dst)`` using the coordinate and matrix types of your custom backend. | | {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.apply_filters` | no | Post-process the raw ``adj``/``dst`` before percentile filtering and transform. | | {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.apply_percentile` | no | Override percentile handling when the backend needs custom behavior. | @@ -60,7 +59,6 @@ import numpy as np from scipy.sparse import csr_matrix from snnpy import build_snn_model -from squidpy._constants._constants import CoordType from squidpy.gr.neighbors import GraphBuilderCSR @@ -71,10 +69,6 @@ class SNNRadiusBuilder(GraphBuilderCSR): super().__init__(**kwargs) self.radius = radius - @property - def coord_type(self) -> CoordType: - return CoordType.GENERIC - def build_graph(self, coords): N = coords.shape[0] model = build_snn_model(coords, verbose=0) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 25dfd0826..5532157d6 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -87,7 +87,7 @@ def _resolve_graph_builder( if coord_type is None: if radius is not None: logg.warning( - f"Graph creation with `radius` is only available when `coord_type = {CoordType.GENERIC!r}` specified. " + "Graph creation with `radius` is only available for generic coordinates. " f"Ignoring parameter `radius = {radius}`." ) coord_type = CoordType.GRID if has_spatial_uns else CoordType.GENERIC @@ -779,7 +779,7 @@ def _run_spatial_neighbors( libs = [None] start = logg.info( - f"Creating graph using `{builder.coord_type}` coordinates and `{builder.transform}` transform and `{len(libs)}` libraries." + f"Creating graph using `{builder.transform}` transform and `{len(libs)}` libraries." ) if library_key is not None: mats: list[tuple[Any, Any]] = [] @@ -800,7 +800,6 @@ def _run_spatial_neighbors( "distances_key": dists_key, "params": { "n_neighbors": getattr(builder, "n_neighs", 6), - "coord_type": builder.coord_type.v, "radius": getattr(builder, "radius", None), "transform": builder.transform.v, }, diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index b5f2aad16..0d880192c 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -26,7 +26,7 @@ from sklearn.metrics.pairwise import cosine_similarity, euclidean_distances from sklearn.neighbors import NearestNeighbors -from squidpy._constants._constants import CoordType, Transform +from squidpy._constants._constants import Transform from squidpy._utils import NDArrayA from squidpy._validators import assert_positive @@ -55,11 +55,6 @@ def __init__( self.set_diag = set_diag self.percentile = percentile - @property - @abstractmethod - def coord_type(self) -> CoordType: - """Coordinate system supported by this builder.""" - def build(self, coords: CoordT) -> tuple[GraphMatrixT, GraphMatrixT]: adj, dst = self.build_graph(coords) adj, dst = self.apply_filters(adj, dst) @@ -127,7 +122,7 @@ def apply_filters(self, adj: csr_matrix, dst: csr_matrix) -> tuple[csr_matrix, c return adj, dst def apply_percentile(self, adj: csr_matrix, dst: csr_matrix) -> tuple[csr_matrix, csr_matrix]: - if self.percentile is not None and self.coord_type == CoordType.GENERIC: + if self.percentile is not None: threshold = np.percentile(dst.data, self.percentile) adj[dst > threshold] = 0.0 dst[dst > threshold] = 0.0 @@ -176,10 +171,6 @@ def __init__( super().__init__(transform=transform, set_diag=set_diag, percentile=percentile) self.n_neighs = n_neighs - @property - def coord_type(self) -> CoordType: - return CoordType.GENERIC - def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: N = coords.shape[0] tree = NearestNeighbors(n_neighbors=self.n_neighs, radius=1, metric="euclidean") @@ -219,10 +210,6 @@ def __init__( super().__init__(transform=transform, set_diag=set_diag, percentile=percentile) self.radius = radius - @property - def coord_type(self) -> CoordType: - return CoordType.GENERIC - def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: N = coords.shape[0] r = self.radius if isinstance(self.radius, int | float) else max(self.radius) @@ -272,10 +259,6 @@ def __init__( super().__init__(transform=transform, set_diag=set_diag, percentile=percentile) self.radius = radius - @property - def coord_type(self) -> CoordType: - return CoordType.GENERIC - def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: N = coords.shape[0] tri = Delaunay(coords) @@ -328,10 +311,6 @@ def __init__( self.n_rings = n_rings self.delaunay = delaunay - @property - def coord_type(self) -> CoordType: - return CoordType.GRID - def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: if self.n_rings > 1: adj = self._base_adjacency(coords, set_diag=True) From 8afcd030b4e559d5ae760c67377da712fb8093a5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 07:04:32 +0000 Subject: [PATCH 49/54] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/squidpy/gr/_build.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 5532157d6..58617d0fb 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -778,9 +778,7 @@ def _run_spatial_neighbors( else: libs = [None] - start = logg.info( - f"Creating graph using `{builder.transform}` transform and `{len(libs)}` libraries." - ) + start = logg.info(f"Creating graph using `{builder.transform}` transform and `{len(libs)}` libraries.") if library_key is not None: mats: list[tuple[Any, Any]] = [] ixs: list[int] = [] From 21f02c376059b06c7579365fb1b0a79b6246dce3 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Mon, 13 Apr 2026 23:27:43 +0200 Subject: [PATCH 50/54] make builder positional consistently --- src/squidpy/gr/_build.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 58617d0fb..bac3aa6fe 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -376,7 +376,7 @@ def spatial_neighbors( ) return _run_spatial_neighbors( - adata, builder=builder, spatial_key=spatial_key, library_key=library_key, key_added=key_added, copy=copy + adata, builder, spatial_key=spatial_key, library_key=library_key, key_added=key_added, copy=copy ) @@ -441,7 +441,7 @@ def spatial_neighbors_from_builder( ) return _run_spatial_neighbors( adata, - builder=builder, + builder, spatial_key=spatial_key, library_key=library_key, key_added=key_added, @@ -527,7 +527,7 @@ def spatial_neighbors_knn( ) return _run_spatial_neighbors( adata, - builder=builder, + builder, spatial_key=spatial_key, library_key=library_key, key_added=key_added, @@ -596,7 +596,7 @@ def spatial_neighbors_radius( ) return _run_spatial_neighbors( adata, - builder=builder, + builder, spatial_key=spatial_key, library_key=library_key, key_added=key_added, @@ -665,7 +665,7 @@ def spatial_neighbors_delaunay( ) return _run_spatial_neighbors( adata, - builder=builder, + builder, spatial_key=spatial_key, library_key=library_key, key_added=key_added, @@ -753,7 +753,7 @@ def spatial_neighbors_grid( ) return _run_spatial_neighbors( adata, - builder=builder, + builder, spatial_key=spatial_key, library_key=library_key, key_added=key_added, @@ -763,8 +763,8 @@ def spatial_neighbors_grid( def _run_spatial_neighbors( adata: AnnData, - *, builder: GraphBuilder[Any, Any], + *, spatial_key: str = Key.obsm.spatial, library_key: str | None = None, key_added: str = "spatial", From 744c757e2825ddc525420e4d00588b4f584b05a2 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Tue, 14 Apr 2026 00:10:38 +0200 Subject: [PATCH 51/54] make more abstractions --- docs/api.md | 4 + docs/extensibility.md | 20 +++-- src/squidpy/gr/_build.py | 4 + src/squidpy/gr/neighbors.py | 168 ++++++++++++++++++++++-------------- 4 files changed, 125 insertions(+), 71 deletions(-) diff --git a/docs/api.md b/docs/api.md index dd19bb18e..4d23a6ed5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -128,6 +128,10 @@ See the {doc}`extensibility guide ` for how to implement a custo gr.neighbors.GraphBuilder gr.neighbors.GraphBuilderCSR + gr.neighbors.GraphPostprocessor + gr.neighbors.DistanceIntervalPostprocessor + gr.neighbors.PercentilePostprocessor + gr.neighbors.TransformPostprocessor gr.neighbors.KNNBuilder gr.neighbors.RadiusBuilder gr.neighbors.DelaunayBuilder diff --git a/docs/extensibility.md b/docs/extensibility.md index 5ad3c0b98..befd61b23 100644 --- a/docs/extensibility.md +++ b/docs/extensibility.md @@ -8,24 +8,28 @@ The `squidpy.gr.neighbors` module exposes two builder base classes: Use it when you want to plug in a custom coordinate type or sparse-matrix backend. - {class}`~squidpy.gr.neighbors.GraphBuilderCSR` is the CSR-specialized builder used by the built-in graph construction strategies. Use it when your builder returns - {class}`~scipy.sparse.csr_matrix` objects and should reuse the default CSR-specific - percentile filtering, transform handling, and sparse warning suppression. + {class}`~scipy.sparse.csr_matrix` objects and should reuse Squidpy's CSR-specific + postprocessors, sparse warning suppression, and multi-library combination. +- Reusable postprocessors such as + {class}`~squidpy.gr.neighbors.DistanceIntervalPostprocessor`, + {class}`~squidpy.gr.neighbors.PercentilePostprocessor`, and + {class}`~squidpy.gr.neighbors.TransformPostprocessor` are also exposed for + custom builder composition. ### What to override | Base class | Method / property | Required | Purpose | |---|---|---|---| | {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.build_graph` | yes | Construct and return ``(adj, dst)`` using the coordinate and matrix types of your custom backend. | -| {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.apply_filters` | no | Post-process the raw ``adj``/``dst`` before percentile filtering and transform. | -| {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.apply_percentile` | no | Override percentile handling when the backend needs custom behavior. | -| {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.apply_transform` | no | Override transform handling when the backend needs custom behavior. | +| {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.postprocessors` | no | Return post-build processing steps for ``(adj, dst)``. You can either override this or pass ``postprocessors=...`` to ``super().__init__()``. | | {class}`~squidpy.gr.neighbors.GraphBuilder` | {meth}`~squidpy.gr.neighbors.GraphBuilder.combine` | no | Combine per-library results when using ``library_key``. If you do not need ``library_key`` support, leaving this unimplemented is fine. | The generic builder only defines the pipeline. The CSR-specialized builder adds -the built-in CSR behavior for percentile filtering, adjacency transforms, -multi-library ``library_key`` combination, and -{class}`~scipy.sparse.SparseEfficiencyWarning` suppression. +multi-library ``library_key`` combination and +{class}`~scipy.sparse.SparseEfficiencyWarning` suppression, while built-in and +custom CSR builders can compose the public reusable postprocessors for +distance-interval pruning, percentile filtering, and adjacency transforms. Here ``adj`` and ``dst`` are square sparse matrices of shape ``(n_obs, n_obs)`` with matching sparsity structure: diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index bac3aa6fe..e2778fa62 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -410,6 +410,10 @@ def spatial_neighbors_from_builder( {{class}}`~squidpy.gr.neighbors.GraphBuilderCSR`, while custom backends can implement the more generic {{class}}`~squidpy.gr.neighbors.GraphBuilder` interface directly. + Reusable post-build operations are also exposed via + :class:`~squidpy.gr.neighbors.DistanceIntervalPostprocessor`, + :class:`~squidpy.gr.neighbors.PercentilePostprocessor`, and + :class:`~squidpy.gr.neighbors.TransformPostprocessor`. Custom builders only need to implement multi-library support when using ``library_key``; otherwise leaving :meth:`~squidpy.gr.neighbors.GraphBuilder.combine` unimplemented is fine. diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index 0d880192c..9a3078e64 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -7,7 +7,8 @@ import warnings from abc import ABC, abstractmethod -from collections.abc import Iterable, Sequence +from collections.abc import Callable, Sequence +from dataclasses import dataclass from itertools import chain from typing import Generic, TypeVar, cast @@ -30,19 +31,32 @@ from squidpy._utils import NDArrayA from squidpy._validators import assert_positive -__all__ = ["GraphBuilder", "GraphBuilderCSR", "KNNBuilder", "RadiusBuilder", "DelaunayBuilder", "GridBuilder"] +__all__ = [ + "GraphBuilder", + "GraphBuilderCSR", + "GraphPostprocessor", + "DistanceIntervalPostprocessor", + "PercentilePostprocessor", + "TransformPostprocessor", + "KNNBuilder", + "RadiusBuilder", + "DelaunayBuilder", + "GridBuilder", +] CoordT = TypeVar("CoordT") GraphMatrixT = TypeVar("GraphMatrixT") +GraphPostprocessor = Callable[[GraphMatrixT, GraphMatrixT], tuple[GraphMatrixT, GraphMatrixT]] class GraphBuilder(ABC, Generic[CoordT, GraphMatrixT]): """Base class for spatial graph construction strategies. Custom builders must implement :meth:`build_graph`. Overriding - :meth:`combine` is optional and only needed to support multi-library graph - construction via ``library_key``. + :meth:`postprocessors` and :meth:`combine` is optional. Postprocessors can + be provided directly via ``__init__`` or by overriding + :meth:`postprocessors`. """ def __init__( @@ -50,31 +64,26 @@ def __init__( transform: str | Transform | None = None, set_diag: bool = False, percentile: float | None = None, + postprocessors: Sequence[GraphPostprocessor[GraphMatrixT]] = (), ) -> None: self.transform = Transform.NONE if transform is None else Transform(transform) self.set_diag = set_diag self.percentile = percentile + self._postprocessors: list[GraphPostprocessor[GraphMatrixT]] = list(postprocessors) def build(self, coords: CoordT) -> tuple[GraphMatrixT, GraphMatrixT]: adj, dst = self.build_graph(coords) - adj, dst = self.apply_filters(adj, dst) - adj, dst = self.apply_percentile(adj, dst) - adj, dst = self.apply_transform(adj, dst) + for postprocessor in self.postprocessors(): + adj, dst = postprocessor(adj, dst) return adj, dst @abstractmethod def build_graph(self, coords: CoordT) -> tuple[GraphMatrixT, GraphMatrixT]: """Construct raw adjacency and distance matrices.""" - def apply_filters(self, adj: GraphMatrixT, dst: GraphMatrixT) -> tuple[GraphMatrixT, GraphMatrixT]: - """Apply builder-specific post-processing filters.""" - return adj, dst - - def apply_percentile(self, adj: GraphMatrixT, dst: GraphMatrixT) -> tuple[GraphMatrixT, GraphMatrixT]: - return adj, dst - - def apply_transform(self, adj: GraphMatrixT, dst: GraphMatrixT) -> tuple[GraphMatrixT, GraphMatrixT]: - return adj, dst + def postprocessors(self) -> Sequence[GraphPostprocessor[GraphMatrixT]]: + """Return post-build processing steps for ``(adj, dst)``.""" + return self._postprocessors def combine( self, @@ -92,12 +101,12 @@ def combine( class GraphBuilderCSR(GraphBuilder[NDArrayA, csr_matrix], ABC): """CSR-based graph construction strategy. - Specializes :class:`GraphBuilder` for sparse CSR matrix output. Adds built-in handling - for percentile-based edge pruning, adjacency transforms (spectral/cosine), - SparseEfficiencyWarning suppression, and multi-library ``library_key`` - combination. All built-in concrete builders + Specializes :class:`GraphBuilder` for sparse CSR matrix output. Adds + SparseEfficiencyWarning suppression and multi-library ``library_key`` + combination. Built-in concrete builders (:class:`KNNBuilder`, :class:`RadiusBuilder`, :class:`DelaunayBuilder`, :class:`GridBuilder`) - inherit from this class. + inherit from this class and declare their postprocessors explicitly in + ``__init__`` using the reusable public postprocessor classes. Subclass this (not the generic :class:`GraphBuilder`) when implementing a builder that returns CSR matrices. @@ -117,30 +126,6 @@ def build(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: """Construct raw adjacency and distance matrices.""" - def apply_filters(self, adj: csr_matrix, dst: csr_matrix) -> tuple[csr_matrix, csr_matrix]: - """Apply builder-specific post-processing filters.""" - return adj, dst - - def apply_percentile(self, adj: csr_matrix, dst: csr_matrix) -> tuple[csr_matrix, csr_matrix]: - if self.percentile is not None: - threshold = np.percentile(dst.data, self.percentile) - adj[dst > threshold] = 0.0 - dst[dst > threshold] = 0.0 - return adj, dst - - def apply_transform(self, adj: csr_matrix, dst: csr_matrix) -> tuple[csr_matrix, csr_matrix]: - adj.eliminate_zeros() - dst.eliminate_zeros() - - if self.transform == Transform.SPECTRAL: - return cast(csr_matrix, _transform_a_spectral(adj)), dst - if self.transform == Transform.COSINE: - return cast(csr_matrix, _transform_a_cosine(adj)), dst - if self.transform == Transform.NONE: - return adj, dst - - raise NotImplementedError(f"Transform `{self.transform}` is not yet implemented.") - def combine( self, mats: Sequence[tuple[csr_matrix, csr_matrix]], @@ -168,7 +153,16 @@ def __init__( percentile: float | None = None, ) -> None: assert_positive(n_neighs, name="n_neighs") - super().__init__(transform=transform, set_diag=set_diag, percentile=percentile) + postprocessors: list[GraphPostprocessor[csr_matrix]] = [] + if percentile is not None: + postprocessors.append(PercentilePostprocessor(percentile)) + postprocessors.append(TransformPostprocessor(Transform.NONE if transform is None else Transform(transform))) + super().__init__( + transform=transform, + set_diag=set_diag, + percentile=percentile, + postprocessors=postprocessors, + ) self.n_neighs = n_neighs def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: @@ -207,7 +201,18 @@ def __init__( set_diag: bool = False, percentile: float | None = None, ) -> None: - super().__init__(transform=transform, set_diag=set_diag, percentile=percentile) + postprocessors: list[GraphPostprocessor[csr_matrix]] = [] + if isinstance(radius, tuple): + postprocessors.append(DistanceIntervalPostprocessor(tuple(sorted(radius)))) + if percentile is not None: + postprocessors.append(PercentilePostprocessor(percentile)) + postprocessors.append(TransformPostprocessor(Transform.NONE if transform is None else Transform(transform))) + super().__init__( + transform=transform, + set_diag=set_diag, + percentile=percentile, + postprocessors=postprocessors, + ) self.radius = radius def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: @@ -231,12 +236,6 @@ def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: dst.setdiag(0.0) return adj, dst - def apply_filters(self, adj: csr_matrix, dst: csr_matrix) -> tuple[csr_matrix, csr_matrix]: - if isinstance(self.radius, Iterable): - _filter_by_radius_interval(adj, dst, self.radius) - return adj, dst - - class DelaunayBuilder(GraphBuilderCSR): """Build a generic point-cloud graph from a Delaunay triangulation. @@ -256,7 +255,18 @@ def __init__( set_diag: bool = False, percentile: float | None = None, ) -> None: - super().__init__(transform=transform, set_diag=set_diag, percentile=percentile) + postprocessors: list[GraphPostprocessor[csr_matrix]] = [] + if isinstance(radius, tuple): + postprocessors.append(DistanceIntervalPostprocessor(tuple(sorted(radius)))) + if percentile is not None: + postprocessors.append(PercentilePostprocessor(percentile)) + postprocessors.append(TransformPostprocessor(Transform.NONE if transform is None else Transform(transform))) + super().__init__( + transform=transform, + set_diag=set_diag, + percentile=percentile, + postprocessors=postprocessors, + ) self.radius = radius def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: @@ -278,12 +288,6 @@ def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: dst.setdiag(0.0) return adj, dst - def apply_filters(self, adj: csr_matrix, dst: csr_matrix) -> tuple[csr_matrix, csr_matrix]: - if isinstance(self.radius, Iterable): - _filter_by_radius_interval(adj, dst, self.radius) - return adj, dst - - class GridBuilder(GraphBuilderCSR): """Build a grid-based spatial graph. @@ -306,7 +310,8 @@ def __init__( ) -> None: assert_positive(n_neighs, name="n_neighs") assert_positive(n_rings, name="n_rings") - super().__init__(transform=transform, set_diag=set_diag, percentile=None) + postprocessors = [TransformPostprocessor(Transform.NONE if transform is None else Transform(transform))] + super().__init__(transform=transform, set_diag=set_diag, percentile=None, postprocessors=postprocessors) self.n_neighs = n_neighs self.n_rings = n_rings self.delaunay = delaunay @@ -369,9 +374,9 @@ def _base_adjacency(self, coords: NDArrayA, *, set_diag: bool) -> csr_matrix: def _filter_by_radius_interval( adj: csr_matrix, dst: csr_matrix, - radius: Iterable[float], + radius: tuple[float, float], ) -> None: - minn, maxx = sorted(radius)[:2] + minn, maxx = radius mask = (dst.data < minn) | (dst.data > maxx) a_diag = adj.diagonal() @@ -379,6 +384,43 @@ def _filter_by_radius_interval( adj.data[mask] = 0.0 adj.setdiag(a_diag) +@dataclass(frozen=True) +class DistanceIntervalPostprocessor: + interval: tuple[float, float] + + def __call__(self, adj: csr_matrix, dst: csr_matrix) -> tuple[csr_matrix, csr_matrix]: + _filter_by_radius_interval(adj, dst, self.interval) + return adj, dst + + +@dataclass(frozen=True) +class PercentilePostprocessor: + percentile: float + + def __call__(self, adj: csr_matrix, dst: csr_matrix) -> tuple[csr_matrix, csr_matrix]: + threshold = np.percentile(dst.data, self.percentile) + adj[dst > threshold] = 0.0 + dst[dst > threshold] = 0.0 + return adj, dst + + +@dataclass(frozen=True) +class TransformPostprocessor: + transform: Transform + + def __call__(self, adj: csr_matrix, dst: csr_matrix) -> tuple[csr_matrix, csr_matrix]: + adj.eliminate_zeros() + dst.eliminate_zeros() + + if self.transform == Transform.SPECTRAL: + return cast(csr_matrix, _transform_a_spectral(adj)), dst + if self.transform == Transform.COSINE: + return cast(csr_matrix, _transform_a_cosine(adj)), dst + if self.transform == Transform.NONE: + return adj, dst + + raise NotImplementedError(f"Transform `{self.transform}` is not yet implemented.") + @njit def _csr_bilateral_diag_scale_helper( From dbc7f0d806ac1e8c60798384e7ba1b8c326b32a3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:10:38 +0000 Subject: [PATCH 52/54] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/squidpy/gr/neighbors.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index 9a3078e64..d99646734 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -236,6 +236,7 @@ def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: dst.setdiag(0.0) return adj, dst + class DelaunayBuilder(GraphBuilderCSR): """Build a generic point-cloud graph from a Delaunay triangulation. @@ -288,6 +289,7 @@ def build_graph(self, coords: NDArrayA) -> tuple[csr_matrix, csr_matrix]: dst.setdiag(0.0) return adj, dst + class GridBuilder(GraphBuilderCSR): """Build a grid-based spatial graph. @@ -384,6 +386,7 @@ def _filter_by_radius_interval( adj.data[mask] = 0.0 adj.setdiag(a_diag) + @dataclass(frozen=True) class DistanceIntervalPostprocessor: interval: tuple[float, float] From d7e5e5f0c58d4043414bb3eb33b896c4844189a8 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Tue, 14 Apr 2026 00:20:16 +0200 Subject: [PATCH 53/54] graphmatrixT exposure --- docs/api.md | 2 ++ pyproject.toml | 2 +- src/squidpy/gr/__init__.py | 2 ++ src/squidpy/gr/_build.py | 7 +++---- src/squidpy/gr/neighbors.py | 1 + 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/api.md b/docs/api.md index 4d23a6ed5..e975967b4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -23,6 +23,7 @@ import squidpy as sq gr.spatial_neighbors_radius gr.spatial_neighbors_delaunay gr.spatial_neighbors_grid + gr.GraphMatrixT gr.SpatialNeighborsResult gr.mask_graph gr.nhood_enrichment @@ -128,6 +129,7 @@ See the {doc}`extensibility guide ` for how to implement a custo gr.neighbors.GraphBuilder gr.neighbors.GraphBuilderCSR + gr.neighbors.GraphMatrixT gr.neighbors.GraphPostprocessor gr.neighbors.DistanceIntervalPostprocessor gr.neighbors.PercentilePostprocessor diff --git a/pyproject.toml b/pyproject.toml index 06e9dfc5a..5dd9499c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -261,7 +261,7 @@ addopts = [ markers = [ "internet: tests that require internet", "gpu: tests that require GPU", -] + ] [tool.coverage] run.branch = true diff --git a/src/squidpy/gr/__init__.py b/src/squidpy/gr/__init__.py index fce615b66..5a9a489fc 100644 --- a/src/squidpy/gr/__init__.py +++ b/src/squidpy/gr/__init__.py @@ -4,6 +4,7 @@ from squidpy.gr import neighbors from squidpy.gr._build import ( + GraphMatrixT, SpatialNeighborsResult, mask_graph, spatial_neighbors, @@ -26,6 +27,7 @@ from squidpy.gr._sepal import sepal __all__ = [ + "GraphMatrixT", "SpatialNeighborsResult", "NhoodEnrichmentResult", "neighbors", diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index e2778fa62..2d9d1b3bd 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -3,7 +3,7 @@ from __future__ import annotations import warnings -from typing import Any, Generic, NamedTuple, TypeVar +from typing import Any, Generic, NamedTuple import geopandas as gpd import numpy as np @@ -36,12 +36,14 @@ from squidpy.gr.neighbors import ( DelaunayBuilder, GraphBuilder, + GraphMatrixT, GridBuilder, KNNBuilder, RadiusBuilder, ) __all__ = [ + "GraphMatrixT", "SpatialNeighborsResult", "spatial_neighbors", "spatial_neighbors_from_builder", @@ -52,9 +54,6 @@ ] -GraphMatrixT = TypeVar("GraphMatrixT") - - class SpatialNeighborsResult(NamedTuple, Generic[GraphMatrixT]): """Result of spatial_neighbors function.""" diff --git a/src/squidpy/gr/neighbors.py b/src/squidpy/gr/neighbors.py index d99646734..422fbf77a 100644 --- a/src/squidpy/gr/neighbors.py +++ b/src/squidpy/gr/neighbors.py @@ -32,6 +32,7 @@ from squidpy._validators import assert_positive __all__ = [ + "GraphMatrixT", "GraphBuilder", "GraphBuilderCSR", "GraphPostprocessor", From 13768e2c72b5307ff67706c810b76d9c3800fafe Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:20:18 +0000 Subject: [PATCH 54/54] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5dd9499c8..06e9dfc5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -261,7 +261,7 @@ addopts = [ markers = [ "internet: tests that require internet", "gpu: tests that require GPU", - ] +] [tool.coverage] run.branch = true