diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index ffacbe3da..db8711149 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -230,6 +230,58 @@ jobs:
fail_ci_if_error: true
+ # Exercise the experimental spatialdata-plot delegation backend end-to-end with the
+ # SQUIDPY_USE_SDATAPLOT flag on. Targets only the delegation suite; the legacy
+ # reference-image suite is skipped under the flag by tests/plotting/conftest.py.
+ test-sdataplot-backend:
+ name: sdata-plot backend (SQUIDPY_USE_SDATAPLOT=1)
+ needs: [ensure-data-is-cached]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ filter: blob:none
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
+ with:
+ python-version: "3.14"
+ cache-dependency-glob: pyproject.toml
+
+ - name: Ensure figure directory exists
+ run: mkdir -p "$GITHUB_WORKSPACE/tests/figures"
+
+ - name: Restore data cache
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: data # IMPORTANT: this will fail if scanpy.settings.datasetdir default changes
+ key: data-${{ hashFiles('**/download_data.py') }}
+ restore-keys: |
+ data-
+ enableCrossOsArchive: true
+
+ - name: System dependencies (Linux)
+ run: |
+ sudo apt-get update -y
+ sudo apt-get install automake -y
+
+ # PyQt5 related
+ sudo apt install libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 -y
+ sudo Xvfb :42 -screen 0 1920x1080x24 -ac +extension GLX Generator[str, None, None]:
+ for line in lines:
+ m = re.fullmatch(r"(?P\w+)\s+:\s+(?P[\w.]+)", line)
+ if m:
+ yield f"-{m['param']} (:class:`~{m['type']}`)"
+ else:
+ yield line
+
+
+def _parse_returns_section(self: NumpyDocstring, section: str) -> list[str]:
+ lines_raw = self._dedent(self._consume_to_next_section())
+ if lines_raw[0] == ":":
+ del lines_raw[0]
+ lines = self._format_block(":returns: ", list(_process_return(lines_raw)))
+ if lines and lines[-1]:
+ lines.append("")
+ return lines
+
+
+def setup(app: Sphinx):
+ """Set app."""
+ NumpyDocstring._parse_returns_section = _parse_returns_section
diff --git a/docs/release/notes-dev.md b/docs/release/notes-dev.md
index 221023e5e..ec885a05c 100644
--- a/docs/release/notes-dev.md
+++ b/docs/release/notes-dev.md
@@ -2,8 +2,13 @@
## Features
+- Add an experimental, opt-in `spatialdata-plot` delegation backend for {func}`squidpy.pl.spatial_scatter` and {func}`squidpy.pl.spatial_segment`, enabled with the `SQUIDPY_USE_SDATAPLOT=1` environment variable. It accepts native {class}`spatialdata.SpatialData` input (in addition to AnnData) and renders through `spatialdata-plot` instead of the legacy matplotlib path. On SpatialData input, `shapes_layer` / `labels_layer` / `points_layer` / `image_layer` / `table` select the element to render when a coordinate system holds more than one candidate.
- {func}`squidpy.experimental.im.calculate_image_features` now featurizes tiles on a shared dask engine: `n_jobs > 1` runs worker processes via a `dask.distributed.LocalCluster` (or an active `Client`), and per-tile BLAS/OpenMP threads are pinned to avoid oversubscription. This also speeds up the serial path. {func}`squidpy.experimental.tl.calculate_tiling_qc` shares the same engine. Adds `distributed` and `threadpoolctl` as dependencies.
- Fix {func}`squidpy.tl.var_by_distance` behaviour when providing {mod}`numpy` arrays of coordinates as anchor point.
- Update :attr:`squidpy.pl.var_by_distance` to show multiple variables on same plot.
[@LLehner](https://github.com/LLehner)
[#929](https://github.com/scverse/squidpy/pull/929)
+
+## Deprecations
+
+- Passing an {class}`anndata.AnnData` to the spatial plotting functions now emits a {class}`DeprecationWarning` under the delegation backend; pass a {class}`spatialdata.SpatialData` instead. AnnData input is slated for removal in squidpy v2.0.
diff --git a/plans/delegate-plots-to-sdata-plot.md b/plans/delegate-plots-to-sdata-plot.md
new file mode 100644
index 000000000..4fc0352cd
--- /dev/null
+++ b/plans/delegate-plots-to-sdata-plot.md
@@ -0,0 +1,215 @@
+# Delegate plots to spatialdata-plot
+
+Tracking issue: scverse/squidpy#912.
+
+## Goal
+
+Replace squidpy's spatial plotting internals with `spatialdata-plot` calls while keeping user-facing signatures unchanged during the deprecation window. Drop the AnnData-input path and the `sq.read.*` readers at v2.0; both are superseded by `spatialdata-io` + `SpatialData` input.
+
+This is a deprecation effort, not a permanent abstraction layer. The AnnData -> SpatialData shim inside the plot wrapper is short-lived and best-effort, not architecture.
+
+## Scope
+
+In scope:
+- Deprecate `sq.read.visium`, `sq.read.nanostring`, `sq.read.vizgen`, and any other AnnData-producing readers in `sq.read`.
+- Migrate `sq.pl.spatial_scatter` and `sq.pl.spatial_segment` to delegate to `spatialdata-plot >= 0.3.4`.
+- Keep public signatures unchanged. Internals route through `render_shapes` / `render_points` / `render_labels` / `render_images` and `show`.
+- Accept both AnnData and SpatialData input during the window; emit `DeprecationWarning` on AnnData.
+
+Out of scope for this initiative:
+- `sq.pl.nhood_enrichment`, `sq.pl.co_occurrence`, `sq.pl.interaction_matrix`, `sq.pl.centrality_scores`, `sq.pl.ripley`, `sq.pl.var_by_distance`. Statistics plots consume analysis results from `.uns`/`.obsp`/`.obsm` and have no `spatialdata-plot` rendering equivalent today. Separate later milestone if migrated at all.
+- `sq.pl.ligrec`. Rank 2 by user engagement (93 historical comments) but `spatialdata-plot` has no cellphoneDB-style dotplot. Decide later whether to upstream or keep native.
+- `sq.pl.extract` is a `obsm` -> `obs` data utility, not a plot. Untouched.
+- `sq.gr.*` analysis functions. Whether they continue to write results into AnnData or into `sdata.tables['table']` is a separate decision.
+- napari integration in `sq.im`/`napari-spatialdata`.
+
+## Plotting surface inventory
+
+Full audit of `sq.pl.*` (10 entries):
+
+| Function | Modality | Classification |
+|---|---|---|
+| `spatial_scatter` | Coords + optional image, parametric markers | Delegate (Stage 2) |
+| `spatial_segment` | Coords + image + raster mask | Delegate (Stage 2) |
+| `ligrec` | Dotplot (size + color matrix) | Native, future decision |
+| `centrality_scores` | Stat scatter per cluster | Native |
+| `interaction_matrix` | Matrix heatmap | Native |
+| `nhood_enrichment` | Matrix heatmap | Native |
+| `ripley` | Line plot vs distance | Native |
+| `co_occurrence` | Per-cluster line plots | Native |
+| `var_by_distance` | Seaborn regression plot | Native |
+| `extract` | Data utility (not a plot) | N/A |
+
+`spatial_scatter` and `spatial_segment` share ~80% of their kwarg surface. Differentiators: scatter owns `shape`/`size`/`size_key`/`scale_factor`/`outline*`/`connectivity_key`/`edges_*`; segment owns `seg_cell_id`/`seg`/`seg_key`/`seg_contourpx`/`seg_outline`. This justifies a single `Intent` shape with element-existence booleans on `DataIntent` rather than a `ScatterIntent | SegmentIntent` union.
+
+## Intent design (locked)
+
+Internal wrapper structure (not public API):
+
+```
+def spatial_scatter(input, **kwargs):
+ intent = capture_plotting_intent(mode="scatter", **kwargs)
+ intent = resolve_intent(input, intent) # adds defaults from data
+ sdata = input if isinstance(input, SpatialData) else _make_tmp_sdata(input, intent)
+ return _render_from_intent(sdata, intent)
+```
+
+Four lifecycle buckets:
+
+**DataIntent** (drives `_make_tmp_sdata` and SpatialData element selection)
+- Element existence flags: `needs_shapes`, `needs_labels`, `needs_points`, `needs_image`, `needs_graph`
+- Element names: `shapes_layer`, `labels_layer`, `image_layer`, `points_layer`, `graph_layer`
+- Library selection: `library_ids`, `library_key`
+- Coordinate system: `coordinate_system`
+- Image source: `img_res_key`, `img_channel`
+- Color source resolution: `color`, `use_raw`, `layer`, `alt_var`
+- Size source: `size`, `size_key`, `scale_factor` (scatter only)
+- Crop: `crop_coord` per library
+- Segmentation mapping: `seg_cell_id` (segment only)
+
+**RenderIntent** (per-element kwargs passed to sdata-plot render calls)
+- Color encoding: `cmap`, `norm` (vmin/vmax/vcenter folded in at capture), `palette`, `alpha`, `na_color`, `groups`
+- Element kind decision: `shape` (drives `render_shapes` vs `render_points`)
+- Image styling: `img_alpha`, `img_cmap`
+- Mask styling: `contour_px` (translated from `seg_contourpx`), outline alpha (translated from `seg_outline`)
+- Outline tuples: `outline`, `outline_color`, `outline_width` -> chain renders the element 3 times (bg, gap, fg) on the same ax
+- Graph styling: `edges_width`, `edges_color`, `edges_kwargs` -> passed to `render_graph`
+
+**LayoutIntent** (matplotlib figure setup before render)
+- Panel grid: `ncols`, `library_first`, `wspace`, `hspace`
+- Figure: `figsize`, `dpi`, `fig`, `ax`, `frameon`
+- Return mode: `return_ax`
+
+**PostRenderIntent** (applied to returned axes after `show()`)
+- Titles: `title`, `axis_label`
+- Legend: `legend_loc` incl. `'on data'` centroid-text interception, `legend_fontsize`, `legend_fontweight`, `legend_fontoutline`, `legend_na`
+- Colorbar: `colorbar`
+- Scalebar: `scalebar_dx`, `scalebar_units`, `scalebar_kwargs` (passthrough to `matplotlib_scalebar`; sdata-plot v0.3.4 wires the first two through `show()`)
+- Save: `save`
+
+### Locked design decisions
+
+1. **Panel expansion happens at capture.** `capture_plotting_intent` flattens `(library_ids x color)` into `Intent.panels: list[PanelIntent]`. Render code is a single loop over panels. Per-library values (`size`, `scalebar_dx`, `crop_coord`) live on `PanelIntent`, not `Intent` root.
+2. **Outline effect lives in RenderIntent** as a flag. Render chain renders the element 3 times (bg, gap, fg) on the same ax. No PostRender re-render, no upstream blocker.
+3. **Connectivity edges are a sibling render call**, not a PostRender hook. `needs_graph` + `graph_layer` on DataIntent; render chain inserts `render_graph()` ahead of `render_points/shapes` so points sit on top. Replaces squidpy's current pre-image `_plot_edges` call.
+4. **`legend_loc='on data'`** is intercepted at capture (sdata-plot rejects it in PR #649). PostRender places centroid text on the returned ax after `show()`.
+5. **Element-name ambiguity on SpatialData input**: if multiple shapes/labels elements exist for the selected coordinate system, the wrapper requires the user to pass explicit `shapes_layer=`/`labels_layer=` (new kwargs on the public signature). Mirrors scanpy's `layer=`.
+6. **`seg_contourpx=1`** is rejected by sdata-plot PR #645; capture validates and raises with a clear message rather than passing through.
+
+## Version timeline
+
+Current release: `v1.8.1`.
+
+| Version | Action |
+|---|---|
+| `v1.9.0` | Stage 1. `DeprecationWarning` on every `sq.read.*` function pointing at the `spatialdata-io` equivalent. No removal. Tutorials updated to `spatialdata-io`. |
+| `v1.10.0` (or `v1.9.x` if cadence permits) | Stage 2. `spatial_scatter` and `spatial_segment` accept SpatialData natively; AnnData input still accepted with `DeprecationWarning` and routed through the shim. |
+| `v2.0.0` | Stage 3 + 4. Remove `sq.read.*`. Remove AnnData input path and shim from `spatial_scatter` / `spatial_segment`. Drop AnnData-side tests. |
+
+Hard rule: no removals before `v2.0.0`. Warnings only during the window.
+
+## Stage 1: deprecate readers (`v1.9.0`)
+
+One PR. Touches `src/squidpy/read/*.py`, docs, tutorials.
+
+Changes per reader:
+- At top of function body: `warnings.warn(..., DeprecationWarning, stacklevel=2)` with a message naming the `spatialdata-io` replacement (`spatialdata_io.visium`, `spatialdata_io.nanostring`, etc.) and the removal target (`v2.0.0`).
+- Docstring gains a `.. deprecated:: 1.9.0` directive with the same pointer.
+- No behavior change.
+
+Docs:
+- Migration note in `docs/release_notes.md`.
+- Update the "Reading data" section to lead with `spatialdata-io`; reduce `sq.read.*` to a deprecated-reference block.
+- Update tutorial notebooks that currently call `sq.read.*` to use `spatialdata-io` instead. Identify these via `grep -rn "sq.read\|squidpy.read" docs/ docs/notebooks/ 2>/dev/null` before the PR.
+
+Tests:
+- Add a test per reader asserting `DeprecationWarning` fires.
+- Existing reader tests stay green (warning is not an error).
+
+## Stage 2: dual-input plot delegation (`v1.10.0`)
+
+One PR per top function (two PRs total). Land `spatial_scatter` first.
+
+### Adapter (shim)
+
+`src/squidpy/pl/_adata_to_sdata.py` (new, internal, leading underscore in public API).
+
+Single function `_adata_to_sdata(adata) -> SpatialData`. Best-effort. Covers Visium (`adata.uns['spatial']`) and segmentation-table style inputs. For each library:
+- Build a `shapes` element from `adata.obsm['spatial']` + `scalefactors[size_key]` so Visium spots arrive as actual circles in data units (resolves the `shape=` question from earlier discussion).
+- Build a `table` element wrapping the AnnData.
+- Build `images` and `labels` elements from `uns['spatial'][library]['images']` and segmentation if present.
+- Set transformations so coordinate systems match per library.
+
+Not polished. Not exposed publicly. Emits one `DeprecationWarning` per call.
+
+### Wrapper translations
+
+For each squidpy kwarg, translate to `spatialdata-plot` call(s):
+
+| Squidpy kwarg | Translation |
+|---|---|
+| `shape=("circle"\|"square"\|"hex")` | `render_shapes` on the shapes element built by the adapter (or already present in SpatialData input). |
+| `shape=None` | `render_points` on a points element derived from `obsm['spatial']`. |
+| `vmin` / `vmax` / `vcenter` | Build `Normalize` or `TwoSlopeNorm`, pass `norm=`. |
+| `axis_label=[x,y]` | `ax.set_xlabel/set_ylabel` after `show()`. |
+| `library_first` | Wrapper owns subplot loop; dispatches `render_*().show(ax=ax_ij)` per cell. |
+| `scalebar_dx`, `scalebar_units` | Pass through to `show()` (#648 in sdata-plot). |
+| `alt_var` | Rename to `gene_symbols` on render call. |
+| `use_raw`, `layer` | Wrapper selects the right `table_layer` or swaps `.X` on a transient SpatialData before the render call. |
+| `connectivity_key` | Wrapper composes `render_graph(...).render_points(...).show()`. |
+| `seg_outline`, `seg_contourpx` | Translate to `render_labels(contour_px=..., outline_alpha=...)`. Reject `contour_px=1` upstream of the render call (sdata-plot #645). |
+| `outline=(c1,c2), outline_width=(w1,w2)` | Two render passes on the same ax. Document as a fallback; consider upstreaming tuple support later. |
+| `legend_loc='on data'` | Intercept before `show()`. Render normally, then place text labels at category centroids on the returned ax. |
+| `ncols`, `wspace`, `hspace`, multi-library grids, N-gene grids | Wrapper builds the matplotlib grid and dispatches per-cell render chains. |
+
+### Input handling
+
+Function entry:
+```
+if isinstance(arg, AnnData):
+ warnings.warn(..., DeprecationWarning, stacklevel=2)
+ sdata = _adata_to_sdata(arg)
+elif isinstance(arg, SpatialData):
+ sdata = arg
+else:
+ raise TypeError(...)
+```
+
+### Tests
+
+- Parameterize existing `test_spatial_scatter` / `test_spatial_segment` tests over `[adata_input, sdata_input]` for the duration of the window.
+- Add a `DeprecationWarning` assertion on the AnnData branch.
+- Reference images will shift (sdata-plot rendering does not pixel-match the current matplotlib paths). Follow the reference-image protocol in `tasks/lessons.md` (CI artifacts, not local generation). Refresh baselines once per migrated function in the same PR that lands the migration.
+
+### Risks
+
+- Reference-image churn. Plan for one baseline-refresh commit per top function.
+- Visium-HD users at 10^5-10^6 bins: `render_shapes` is per-geometry. Benchmark on a Visium HD fixture before merging Stage 2; if unacceptable, extend `render_points` upstream with a "size in data units" mode rather than densify shapes.
+- Non-Visium AnnData-only users (custom readers): the shim must not silently drop their data. Add a clear `NotImplementedError` for unrecognized AnnData layouts pointing at the migration guide.
+
+## Stage 3: remove AnnData input from plots (`v2.0.0`)
+
+- Delete `_adata_to_sdata.py`.
+- Function bodies: replace `isinstance(arg, AnnData)` branch with a `TypeError` carrying the migration pointer.
+- Drop AnnData-side test parameterizations.
+- Signatures unchanged except for the parameter type annotation: `adata: AnnData | SpatialData` -> `sdata: SpatialData` (renaming the kwarg also; accept old name with a `FutureWarning` for one minor if practical, otherwise hard rename and document).
+
+## Stage 4: remove readers (`v2.0.0`)
+
+Same release as Stage 3. Delete `src/squidpy/read/*.py`. Drop reader tests. Migration guide stays.
+
+## Communication plan
+
+Not optional given the surface this touches.
+
+- `v1.9.0` changelog: top-line entry "Readers deprecated, will be removed in v2.0".
+- `v1.10.0` changelog: top-line entry "Spatial plots delegate to spatialdata-plot; AnnData input deprecated, will be removed in v2.0".
+- Update issue #912 with the timeline at the start of Stage 1.
+- Cross-post to the scverse zulip / spatialdata channel at each stage transition.
+- Pin a migration guide in `docs/` linked from the package README until v2.0 ships.
+
+## Open questions (resolve before Stage 2)
+
+1. ligrec future: upstream cellphoneDB-style dotplot to sdata-plot, or keep ligrec native and consume `sdata.tables['table']`? Affects whether ligrec's signature also gains SpatialData input in `v1.10`.
+2. Statistics plots: in `v2.0`, do they accept SpatialData only, or both? Cleanest is to do them as part of v2.0 in a follow-up PR. Mark separate.
+3. Reader replacements that `spatialdata-io` does not yet cover (if any): audit `sq.read` against `spatialdata-io` before Stage 1 to confirm every deprecated reader has a real replacement.
diff --git a/src/squidpy/pl/_sdata_delegation/__init__.py b/src/squidpy/pl/_sdata_delegation/__init__.py
new file mode 100644
index 000000000..a6ccb5191
--- /dev/null
+++ b/src/squidpy/pl/_sdata_delegation/__init__.py
@@ -0,0 +1,86 @@
+from __future__ import annotations
+
+import warnings
+from collections.abc import Callable
+from typing import Any
+
+from anndata import AnnData
+from matplotlib.axes import Axes
+from matplotlib.figure import Figure
+from spatialdata import SpatialData
+
+from ._adapter import _make_tmp_sdata
+from ._capture import capture_scatter_intent, capture_segment_intent
+from ._intent import Intent
+from ._render import _render_from_intent
+
+_ANNDATA_DEPRECATION = (
+ "Passing an AnnData to squidpy spatial plotting is deprecated and will be removed in "
+ "squidpy v2.0; pass a SpatialData object instead."
+)
+
+
+def _warn_anndata_input() -> None:
+ warnings.warn(_ANNDATA_DEPRECATION, DeprecationWarning, stacklevel=3)
+
+
+def _resolve_use_raw(adata: AnnData, use_raw: bool | None, layer: str | None = None) -> AnnData:
+ """Swap adata.X with adata.raw.X when use_raw resolves True, preserving obs/obsm/uns.
+
+ Matches legacy squidpy/scanpy semantics: ``use_raw=None`` resolves to True when no
+ layer is requested and ``adata.raw`` exists. Without this, flipping the delegation
+ flag would silently plot ``.X`` where the legacy path plotted raw counts.
+ """
+ if use_raw is None:
+ use_raw = layer is None and adata.raw is not None
+ if not use_raw:
+ return adata
+ if adata.raw is None:
+ raise ValueError("use_raw=True but adata.raw is None.")
+ raw = adata.raw.to_adata()
+ raw.obs = adata.obs.copy()
+ raw.obsm = adata.obsm.copy()
+ raw.uns = dict(adata.uns)
+ return raw
+
+
+def _delegate(
+ input_obj: AnnData | SpatialData,
+ capture: Callable[..., Intent],
+ **kwargs: Any,
+) -> Figure | Axes | list[Axes] | None:
+ """Shared input dispatch for the delegation entrypoints.
+
+ SpatialData renders directly; AnnData goes through the transient-sdata shim
+ (deprecated) after resolving ``use_raw``. ``capture`` is the per-mode intent builder.
+ """
+ if isinstance(input_obj, SpatialData):
+ if kwargs.get("use_raw"):
+ raise ValueError("`use_raw` is AnnData-only; SpatialData has no `.raw`.")
+ return _render_from_intent(input_obj, capture(input_obj, **kwargs))
+ if not isinstance(input_obj, AnnData):
+ raise TypeError(f"Expected AnnData or SpatialData, got {type(input_obj).__name__}.")
+
+ _warn_anndata_input()
+ intent = capture(input_obj, **kwargs)
+ resolved_adata = _resolve_use_raw(input_obj, intent.data.use_raw, intent.data.layer)
+ return _render_from_intent(_make_tmp_sdata(resolved_adata, intent), intent)
+
+
+def _spatial_scatter_via_sdata_plot(
+ input_obj: AnnData | SpatialData,
+ **kwargs: Any,
+) -> Figure | Axes | list[Axes] | None:
+ """spatial_scatter delegation (Paths 1+2): AnnData (shim, deprecated) or SpatialData."""
+ return _delegate(input_obj, capture_scatter_intent, **kwargs)
+
+
+def _spatial_segment_via_sdata_plot(
+ input_obj: AnnData | SpatialData,
+ **kwargs: Any,
+) -> Figure | Axes | list[Axes] | None:
+ """spatial_segment delegation (Path 3): AnnData (shim, deprecated) or SpatialData."""
+ return _delegate(input_obj, capture_segment_intent, **kwargs)
+
+
+__all__ = ["_spatial_scatter_via_sdata_plot", "_spatial_segment_via_sdata_plot"]
diff --git a/src/squidpy/pl/_sdata_delegation/_adapter.py b/src/squidpy/pl/_sdata_delegation/_adapter.py
new file mode 100644
index 000000000..26679ac4d
--- /dev/null
+++ b/src/squidpy/pl/_sdata_delegation/_adapter.py
@@ -0,0 +1,161 @@
+from __future__ import annotations
+
+import numpy as np
+import pandas as pd
+from anndata import AnnData
+from spatialdata import SpatialData
+from spatialdata.models import Image2DModel, Labels2DModel, PointsModel, ShapesModel, TableModel
+from spatialdata.transformations import Identity, Scale, set_transformation
+
+from squidpy._constants._pkg_constants import Key
+
+from ._intent import Intent
+
+_REGION_KEY = "_sq_region"
+_INSTANCE_KEY = "_sq_instance"
+
+
+def _shapes_name(library_id: str) -> str:
+ return f"{library_id}_spots"
+
+
+def _image_name(library_id: str) -> str:
+ return f"{library_id}_image"
+
+
+def _labels_name(library_id: str) -> str:
+ return f"{library_id}_labels"
+
+
+def _points_name(library_id: str) -> str:
+ return f"{library_id}_points"
+
+
+def _table_name(library_id: str) -> str:
+ return f"{library_id}_table"
+
+
+def _build_shapes(adata_sub: AnnData, spatial_key: str, diameter_fullres: float) -> ShapesModel:
+ coords = np.asarray(adata_sub.obsm[spatial_key], dtype=float)
+ return ShapesModel.parse(coords, geometry=0, radius=float(diameter_fullres) / 2.0)
+
+
+def _build_points(adata_sub: AnnData, spatial_key: str) -> PointsModel:
+ coords = np.asarray(adata_sub.obsm[spatial_key], dtype=float)
+ df = pd.DataFrame({"x": coords[:, 0], "y": coords[:, 1]})
+ return PointsModel.parse(df)
+
+
+def _build_image(image_array, scalef: float, coordinate_system: str) -> Image2DModel:
+ """Wrap an image as Image2DModel without materializing a dask-backed array.
+
+ Uses np.moveaxis (NumPy and Dask compatible) instead of np.asarray+transpose,
+ so a 100k x 100k Visium HD H&E stays lazy until render time.
+ """
+ if image_array.ndim == 3 and image_array.shape[-1] in (3, 4):
+ arr = np.moveaxis(image_array, -1, 0)
+ elif image_array.ndim == 2:
+ arr = image_array[np.newaxis, ...]
+ elif image_array.ndim == 3:
+ arr = image_array
+ else:
+ raise ValueError(f"Unexpected image shape {image_array.shape}; need 2D or 3D.")
+ image = Image2DModel.parse(arr, dims=("c", "y", "x"))
+ transform = Scale([1.0 / scalef, 1.0 / scalef], axes=("x", "y")) if scalef != 1.0 else Identity()
+ set_transformation(image, transform, to_coordinate_system=coordinate_system)
+ return image
+
+
+def _build_labels(mask, scalef: float, coordinate_system: str) -> Labels2DModel:
+ if mask.ndim != 2:
+ raise ValueError(f"Labels mask must be 2D, got shape {mask.shape}.")
+ labels = Labels2DModel.parse(mask, dims=("y", "x"))
+ transform = Scale([1.0 / scalef, 1.0 / scalef], axes=("x", "y")) if scalef != 1.0 else Identity()
+ set_transformation(labels, transform, to_coordinate_system=coordinate_system)
+ return labels
+
+
+def _instance_ids(adata_sub: AnnData, kind: str, seg_cell_id: str | None) -> np.ndarray:
+ if kind == "labels" and seg_cell_id is not None:
+ return adata_sub.obs[seg_cell_id].astype(int).to_numpy()
+ return np.arange(adata_sub.n_obs)
+
+
+def _make_tmp_sdata(adata: AnnData, intent: Intent) -> SpatialData:
+ """Build a transient SpatialData from a Visium-style AnnData based on the captured Intent.
+
+ One coordinate system per library, and **one table per library**. Per-library tables
+ avoid materializing a cross-library obsp via ad.concat(pairwise=True), which at Visium HD
+ multi-library scale would be O(N_total^2). Each library's table annotates only its own
+ element via _REGION_KEY / _INSTANCE_KEY, and render_* calls pass table_name=f'{lib}_table'.
+ """
+ images: dict[str, object] = {}
+ shapes: dict[str, object] = {}
+ labels: dict[str, object] = {}
+ points: dict[str, object] = {}
+ tables: dict[str, object] = {}
+
+ library_key = intent.data.library_key
+ library_ids = intent.data.library_ids
+ spatial_key = intent.data.coordinate_system or Key.obsm.spatial
+ size_key = intent.data.size_key or Key.uns.size_key
+ img_res_key = intent.data.img_res_key
+ seg_cell_id = intent.data.seg_cell_id
+ kind = intent.data.element_kind
+
+ for lib in library_ids:
+ if library_key is not None and library_key in adata.obs.columns:
+ mask = adata.obs[library_key].astype(str).values == lib
+ adata_sub = adata[mask].copy()
+ else:
+ adata_sub = adata.copy()
+
+ try:
+ spatial_meta = adata.uns[Key.uns.spatial][lib]
+ except KeyError as e:
+ # ponytail: the AnnData shim only understands the Visium uns[spatial] layout.
+ raise KeyError(
+ f"Library {lib!r} not found in adata.uns[{Key.uns.spatial!r}]. The AnnData "
+ "shim only supports the Visium-style uns[spatial] layout; pass a SpatialData "
+ "object for other layouts."
+ ) from e
+
+ if kind == "shapes":
+ diameter = Key.uns.spot_diameter(adata, Key.uns.spatial, lib, spot_diameter_key=size_key)
+ element = _build_shapes(adata_sub, spatial_key, diameter)
+ set_transformation(element, Identity(), to_coordinate_system=lib)
+ region_name = _shapes_name(lib)
+ shapes[region_name] = element
+ elif kind == "points":
+ element = _build_points(adata_sub, spatial_key)
+ set_transformation(element, Identity(), to_coordinate_system=lib)
+ region_name = _points_name(lib)
+ points[region_name] = element
+ else: # labels
+ seg_key = Key.uns.image_seg_key
+ if seg_key not in spatial_meta["images"]:
+ raise KeyError(f"Library {lib!r} has no '{seg_key}' image in uns[spatial][{lib}][images].")
+ scalef_lookup = f"tissue_{seg_key}_scalef"
+ seg_scalef = float(spatial_meta["scalefactors"].get(scalef_lookup, 1.0))
+ element = _build_labels(spatial_meta["images"][seg_key], seg_scalef, lib)
+ region_name = _labels_name(lib)
+ labels[region_name] = element
+
+ if intent.data.needs_image and img_res_key is not None:
+ if intent.data.scale_factor is not None:
+ scalef = float(intent.data.scale_factor)
+ else:
+ scalef_lookup = f"tissue_{img_res_key}_scalef"
+ scalef = float(spatial_meta["scalefactors"].get(scalef_lookup, 1.0))
+ images[_image_name(lib)] = _build_image(spatial_meta["images"][img_res_key], scalef, lib)
+
+ adata_sub.obs[_REGION_KEY] = pd.Categorical([region_name] * adata_sub.n_obs)
+ adata_sub.obs[_INSTANCE_KEY] = _instance_ids(adata_sub, kind, seg_cell_id)
+ tables[_table_name(lib)] = TableModel.parse(
+ adata_sub,
+ region=region_name,
+ region_key=_REGION_KEY,
+ instance_key=_INSTANCE_KEY,
+ )
+
+ return SpatialData(images=images, shapes=shapes, labels=labels, points=points, tables=tables)
diff --git a/src/squidpy/pl/_sdata_delegation/_capture.py b/src/squidpy/pl/_sdata_delegation/_capture.py
new file mode 100644
index 000000000..d85e20ed3
--- /dev/null
+++ b/src/squidpy/pl/_sdata_delegation/_capture.py
@@ -0,0 +1,630 @@
+from __future__ import annotations
+
+import itertools
+import warnings
+from collections.abc import Sequence
+from dataclasses import replace
+from typing import Any
+
+from anndata import AnnData
+from matplotlib.colors import Normalize, TwoSlopeNorm
+from spatialdata import SpatialData
+
+from squidpy._constants._pkg_constants import Key
+
+from ._intent import (
+ DataIntent,
+ ElementKind,
+ Intent,
+ LayoutIntent,
+ PanelIntent,
+ PostRenderIntent,
+ RenderIntent,
+)
+from ._source import _AnnDataSource, _Source, _SpatialDataSource
+
+
+def _build_norm(
+ vmin: float | None,
+ vmax: float | None,
+ vcenter: float | None,
+ norm: Normalize | None,
+) -> Normalize | None:
+ """Fold vmin/vmax/vcenter into a matplotlib Normalize.
+
+ sdata-plot v0.3.4 dropped vmin/vmax kwargs (#652); the wrapper builds
+ the Normalize and passes it through `norm=`.
+ """
+ if norm is not None:
+ if any(v is not None for v in (vmin, vmax, vcenter)):
+ raise ValueError("Pass either `norm=` or `vmin`/`vmax`/`vcenter`, not both.")
+ return norm
+ if all(v is None for v in (vmin, vmax, vcenter)):
+ return None
+ if vcenter is not None:
+ return TwoSlopeNorm(vmin=vmin, vmax=vmax, vcenter=vcenter)
+ return Normalize(vmin=vmin, vmax=vmax)
+
+
+def _make_source(
+ data: AnnData | SpatialData,
+ *,
+ shapes_layer: str | None,
+ labels_layer: str | None,
+ points_layer: str | None,
+ image_layer: str | None,
+ table: str | None,
+) -> _Source:
+ if isinstance(data, SpatialData):
+ return _SpatialDataSource(
+ data,
+ shapes_layer=shapes_layer,
+ labels_layer=labels_layer,
+ points_layer=points_layer,
+ image_layer=image_layer,
+ table=table,
+ )
+ return _AnnDataSource(data)
+
+
+def _assign_names(
+ panels: tuple[PanelIntent, ...],
+ source: _Source,
+ kind: ElementKind,
+ *,
+ needs_image: bool,
+) -> tuple[PanelIntent, ...]:
+ """Resolve and attach the SpatialData element names each panel renders.
+
+ Names depend only on ``library_id``, so resolve once per unique library and reuse
+ across that library's color panels (avoids re-running ``filter_by_coordinate_system``
+ for every (library x color) panel on SpatialData input).
+ """
+ resolved = {
+ lib: (
+ source.element_name(lib, kind),
+ source.image_name(lib) if needs_image else None,
+ source.table_name(lib),
+ )
+ for lib in dict.fromkeys(p.library_id for p in panels)
+ }
+ out = []
+ for p in panels:
+ element_name, image_name, table_name = resolved[p.library_id]
+ out.append(replace(p, element_name=element_name, image_name=image_name, table_name=table_name))
+ return tuple(out)
+
+
+def _normalize_color(color: str | Sequence[str] | None) -> tuple[str, ...]:
+ if isinstance(color, str):
+ return (color,)
+ if color is None:
+ return ()
+ return tuple(color)
+
+
+def _normalize_groups(groups: str | Sequence[str] | None) -> tuple[str, ...] | None:
+ if groups is None:
+ return None
+ if isinstance(groups, str):
+ return (groups,)
+ return tuple(groups)
+
+
+def _downgrade_on_data_legend(legend_loc: str | None) -> str | None:
+ """Warn and fall back to the default for the unsupported ``legend_loc='on data'``."""
+ if legend_loc == "on data":
+ warnings.warn(
+ "legend_loc='on data' is deprecated for spatial plots: known to be unreliable "
+ "in coordinate space and slated for removal. Use the default 'right margin' or pass "
+ "legend_loc=None to hide.",
+ DeprecationWarning,
+ stacklevel=3,
+ )
+ return "right margin"
+ return legend_loc
+
+
+def _normalize_axis_label(axis_label: str | Sequence[str] | None) -> tuple[str, ...] | None:
+ """Normalize axis_label to a (xlabel[, ylabel]) tuple; a bare str sets the x-axis only."""
+ if axis_label is None:
+ return None
+ if isinstance(axis_label, str):
+ return (axis_label,)
+ return tuple(axis_label)
+
+
+def _per_library(
+ value: Any, library_ids: tuple[str, ...], name: str, *, ambiguous_tuple: bool = True
+) -> tuple[Any, ...]:
+ """Broadcast a scalar or validate a sequence to library count.
+
+ With ``ambiguous_tuple=True`` (default for crop_coord etc.), a 2- or 4-tuple of
+ numbers is treated as a single value to broadcast. With ``ambiguous_tuple=False``
+ (size, scalebar_dx, etc.), any sequence is treated as per-library.
+ """
+ if value is None:
+ return tuple(None for _ in library_ids)
+ is_seq = isinstance(value, (list, tuple))
+ looks_like_single_tuple = (
+ ambiguous_tuple and is_seq and len(value) in (2, 4) and all(isinstance(v, (int, float)) for v in value)
+ )
+ if is_seq and not looks_like_single_tuple:
+ if len(value) != len(library_ids):
+ raise ValueError(f"`{name}` length {len(value)} != number of libraries {len(library_ids)}.")
+ return tuple(value)
+ return tuple(value for _ in library_ids)
+
+
+def _resolve_palette(palette: Any) -> tuple[Any, Any, Any, tuple[str, ...] | None]:
+ """Route a squidpy `palette` value to the right sdata-plot slot.
+
+ Returns ``(palette, cmap, color_override, groups)``. sdata-plot's render_shapes rejects
+ ``palette`` without ``groups``, but accepts ``Colormap`` via ``cmap`` (sampled by
+ category index for categorical color). Mapping:
+
+ - ``None`` -> passthrough
+ - dict {category: color} -> palette + groups from keys
+ - ``Colormap`` / ``ListedColormap`` -> cmap
+ - list of color strings -> wrap as ListedColormap -> cmap
+ - single mpl-recognized color str/tuple -> color_override (set as the literal panel color)
+ - other str (e.g. palette name) -> passthrough as palette
+ """
+ from matplotlib.colors import Colormap, ListedColormap, is_color_like
+
+ if palette is None:
+ return None, None, None, None
+ if isinstance(palette, dict):
+ return palette, None, None, tuple(palette.keys())
+ if isinstance(palette, Colormap):
+ return None, palette, None, None
+ if isinstance(palette, (list, tuple)):
+ return None, ListedColormap(list(palette)), None, None
+ if isinstance(palette, str) and is_color_like(palette):
+ return None, None, palette, None
+ return palette, None, None, None
+
+
+def _expand_panels(
+ library_ids: tuple[str, ...],
+ color_tuple: tuple[str, ...],
+ library_first: bool,
+ crop_coord_per_lib: tuple[Any, ...],
+ scalebar_dx_per_lib: tuple[Any, ...],
+ scalebar_units_per_lib: tuple[Any, ...],
+ size_per_lib: tuple[Any, ...],
+ title: str | Sequence[str] | None,
+) -> tuple[PanelIntent, ...]:
+ """Flatten (library x color) into a panel list with the requested iteration order."""
+ colors = color_tuple if color_tuple else (None,)
+ if library_first:
+ pairs = list(itertools.product(library_ids, colors))
+ else:
+ pairs = [(lib, col) for col, lib in itertools.product(colors, library_ids)]
+
+ if isinstance(title, str):
+ titles = [title] * len(pairs)
+ elif title is None:
+ titles = [None] * len(pairs)
+ else:
+ titles_seq = tuple(title)
+ if len(titles_seq) != len(pairs):
+ raise ValueError(f"`title` length {len(titles_seq)} != number of panels {len(pairs)}.")
+ titles = list(titles_seq)
+
+ lib_index = {lib: i for i, lib in enumerate(library_ids)}
+ panels = []
+ for (lib, col), t in zip(pairs, titles, strict=True):
+ i = lib_index[lib]
+ panels.append(
+ PanelIntent(
+ library_id=lib,
+ color=col,
+ size=size_per_lib[i],
+ crop_coord=crop_coord_per_lib[i],
+ scalebar_dx=scalebar_dx_per_lib[i],
+ scalebar_units=scalebar_units_per_lib[i],
+ title=t,
+ )
+ )
+ return tuple(panels)
+
+
+def _validate_ax(ax: Any, n_panels: int) -> tuple[Any, ...] | None:
+ """Normalize user-supplied `ax` into a tuple matching panel count."""
+ if ax is None:
+ return None
+ from matplotlib.axes import Axes
+
+ if isinstance(ax, Axes):
+ ax_seq = (ax,)
+ else:
+ ax_seq = tuple(ax)
+ if len(ax_seq) != n_panels:
+ raise ValueError(f"`ax` has {len(ax_seq)} axes but {n_panels} panels are required.")
+ return ax_seq
+
+
+def _apply_color_override(
+ panels: tuple[PanelIntent, ...],
+ color_override: Any,
+ color_tuple: tuple[str, ...],
+) -> tuple[PanelIntent, ...]:
+ """Replace the `color` field on each panel with a literal color when the user
+ passed a single color string as `palette` and no explicit `color` column."""
+ if color_override is None or color_tuple:
+ return panels
+ return tuple(replace(p, color=color_override) for p in panels)
+
+
+def capture_scatter_intent(
+ data: AnnData | SpatialData,
+ *,
+ shape: str | None = "circle",
+ color: str | Sequence[str] | None = None,
+ groups: str | Sequence[str] | None = None,
+ img: bool = True,
+ img_res_key: str = Key.uns.image_res_key,
+ library_key: str | None = None,
+ library_id: str | Sequence[str] | None = None,
+ spatial_key: str = Key.obsm.spatial,
+ size_key: str = Key.uns.size_key,
+ palette: Any = None,
+ cmap: Any = None,
+ norm: Normalize | None = None,
+ vmin: float | None = None,
+ vmax: float | None = None,
+ vcenter: float | None = None,
+ alpha: float = 1.0,
+ na_color: Any = (0.0, 0.0, 0.0, 0.0),
+ use_raw: bool | None = None,
+ layer: str | None = None,
+ alt_var: str | None = None,
+ outline: bool = False,
+ outline_color: tuple[str, str] = ("black", "white"),
+ outline_width: tuple[float, float] = (0.3, 0.05),
+ size: float | Sequence[float] | None = None,
+ connectivity_key: str | None = None,
+ edges_width: float = 1.0,
+ edges_color: str | Sequence[str] = "grey",
+ edges_kwargs: Any = None,
+ img_alpha: float | None = None,
+ img_cmap: Any = None,
+ img_channel: int | tuple[int, ...] | None = None,
+ crop_coord: tuple[float, float, float, float] | Sequence[tuple[float, float, float, float]] | None = None,
+ scalebar_dx: float | Sequence[float] | None = None,
+ scalebar_units: str | Sequence[str] | None = None,
+ scalebar_kwargs: Any = None,
+ title: str | Sequence[str] | None = None,
+ axis_label: str | Sequence[str] | None = None,
+ frameon: bool | None = None,
+ colorbar: bool = True,
+ legend_loc: str | None = "right margin",
+ legend_fontsize: Any = None,
+ legend_fontweight: Any = "bold",
+ legend_fontoutline: int | None = None,
+ legend_na: bool = True,
+ ncols: int = 4,
+ library_first: bool = True,
+ wspace: float | None = None,
+ hspace: float | None = None,
+ figsize: tuple[float, float] | None = None,
+ dpi: int | None = None,
+ fig: Any = None,
+ ax: Any = None,
+ save: str | None = None,
+ return_ax: bool = False,
+ scale_factor: float | None = None,
+ shapes_layer: str | None = None,
+ points_layer: str | None = None,
+ image_layer: str | None = None,
+ table: str | None = None,
+ **unsupported: Any,
+) -> Intent:
+ """Capture squidpy spatial_scatter kwargs into an Intent.
+
+ Accepts AnnData or SpatialData. Unknown kwargs raise NotImplementedError;
+ ``legend_loc='on data'`` emits a DeprecationWarning and falls back to the
+ default. ``spatial_key`` and ``connectivity_key`` are supported.
+
+ On SpatialData input, ``shapes_layer`` / ``points_layer`` / ``image_layer`` /
+ ``table`` disambiguate which element to render when a coordinate system holds
+ more than one candidate (they are ignored for AnnData input).
+ """
+ if unsupported:
+ offenders = sorted(unsupported)
+ raise NotImplementedError(f"spatial_scatter via spatialdata-plot does not yet support kwargs: {offenders}.")
+ legend_loc = _downgrade_on_data_legend(legend_loc)
+
+ if shape is not None and shape not in {"circle", "hex", "square", "visium_hex"}:
+ raise ValueError(f"shape must be None or one of {{'circle','hex','square','visium_hex'}}; got {shape!r}.")
+ use_points = shape is None
+
+ color_tuple = _normalize_color(color)
+ source = _make_source(
+ data,
+ shapes_layer=shapes_layer,
+ labels_layer=None,
+ points_layer=points_layer,
+ image_layer=image_layer,
+ table=table,
+ )
+ library_ids = source.library_ids(library_key, library_id)
+
+ crop_per_lib = _per_library(crop_coord, library_ids, "crop_coord")
+ scalebar_dx_per_lib = _per_library(scalebar_dx, library_ids, "scalebar_dx")
+ scalebar_units_per_lib = _per_library(scalebar_units, library_ids, "scalebar_units")
+ size_per_lib = _per_library(size, library_ids, "size", ambiguous_tuple=False)
+
+ panels = _expand_panels(
+ library_ids,
+ color_tuple,
+ library_first,
+ crop_per_lib,
+ scalebar_dx_per_lib,
+ scalebar_units_per_lib,
+ size_per_lib,
+ title,
+ )
+
+ ax_seq = _validate_ax(ax, len(panels))
+
+ data_intent = DataIntent(
+ element_kind="points" if use_points else "shapes",
+ needs_image=bool(img),
+ needs_graph=connectivity_key is not None,
+ library_ids=library_ids,
+ library_key=library_key,
+ coordinate_system=spatial_key,
+ img_res_key=img_res_key if img else None,
+ img_channel=img_channel,
+ color=color_tuple,
+ use_raw=use_raw,
+ layer=layer,
+ alt_var=alt_var,
+ size_key=size_key,
+ graph_layer=connectivity_key,
+ scale_factor=scale_factor,
+ )
+
+ resolved_norm = _build_norm(vmin=vmin, vmax=vmax, vcenter=vcenter, norm=norm)
+ resolved_palette, palette_cmap, color_override, inferred_groups = _resolve_palette(palette)
+ resolved_cmap = palette_cmap if cmap is None else cmap
+ groups_tuple = _normalize_groups(groups) or inferred_groups
+ panels = _apply_color_override(panels, color_override, color_tuple)
+ panels = _assign_names(
+ panels,
+ source,
+ data_intent.element_kind,
+ needs_image=data_intent.needs_image,
+ )
+
+ render = RenderIntent(
+ shape=shape,
+ palette=resolved_palette,
+ cmap=resolved_cmap,
+ norm=resolved_norm,
+ alpha=alpha,
+ na_color=na_color,
+ groups=groups_tuple,
+ outline=outline,
+ outline_color=outline_color,
+ outline_width=outline_width,
+ img_alpha=img_alpha,
+ img_cmap=img_cmap,
+ edges_width=edges_width,
+ edges_color=edges_color,
+ edges_kwargs=edges_kwargs or {},
+ )
+
+ layout = LayoutIntent(
+ ncols=ncols,
+ library_first=library_first,
+ wspace=wspace,
+ hspace=hspace,
+ figsize=figsize,
+ dpi=dpi,
+ frameon=frameon,
+ return_ax=return_ax,
+ fig=fig,
+ ax=ax_seq,
+ )
+
+ post = PostRenderIntent(
+ axis_label=_normalize_axis_label(axis_label),
+ legend_loc=legend_loc,
+ legend_fontsize=legend_fontsize,
+ legend_fontweight=legend_fontweight,
+ legend_fontoutline=legend_fontoutline,
+ legend_na=legend_na,
+ colorbar=colorbar,
+ scalebar_params=scalebar_kwargs,
+ save=save,
+ )
+
+ return Intent(
+ mode="scatter",
+ data=data_intent,
+ render=render,
+ layout=layout,
+ post=post,
+ panels=panels,
+ )
+
+
+def capture_segment_intent(
+ data: AnnData | SpatialData,
+ *,
+ seg_cell_id: str,
+ color: str | Sequence[str] | None = None,
+ groups: str | Sequence[str] | None = None,
+ seg_key: str = Key.uns.image_seg_key,
+ seg_contourpx: int | None = None,
+ seg_outline: bool = False,
+ img: bool = True,
+ img_res_key: str = Key.uns.image_res_key,
+ library_key: str | None = None,
+ library_id: str | Sequence[str] | None = None,
+ spatial_key: str = Key.obsm.spatial,
+ palette: Any = None,
+ cmap: Any = None,
+ norm: Normalize | None = None,
+ vmin: float | None = None,
+ vmax: float | None = None,
+ vcenter: float | None = None,
+ alpha: float = 1.0,
+ na_color: Any = (0.0, 0.0, 0.0, 0.0),
+ use_raw: bool | None = None,
+ layer: str | None = None,
+ alt_var: str | None = None,
+ img_alpha: float | None = None,
+ img_cmap: Any = None,
+ img_channel: int | tuple[int, ...] | None = None,
+ crop_coord: tuple[float, float, float, float] | Sequence[tuple[float, float, float, float]] | None = None,
+ scalebar_dx: float | Sequence[float] | None = None,
+ scalebar_units: str | Sequence[str] | None = None,
+ scalebar_kwargs: Any = None,
+ title: str | Sequence[str] | None = None,
+ axis_label: str | Sequence[str] | None = None,
+ frameon: bool | None = None,
+ colorbar: bool = True,
+ legend_loc: str | None = "right margin",
+ legend_fontsize: Any = None,
+ legend_fontweight: Any = "bold",
+ legend_fontoutline: int | None = None,
+ legend_na: bool = True,
+ ncols: int = 4,
+ library_first: bool = True,
+ wspace: float | None = None,
+ hspace: float | None = None,
+ figsize: tuple[float, float] | None = None,
+ dpi: int | None = None,
+ fig: Any = None,
+ ax: Any = None,
+ save: str | None = None,
+ return_ax: bool = False,
+ scale_factor: float | None = None,
+ labels_layer: str | None = None,
+ image_layer: str | None = None,
+ table: str | None = None,
+ **unsupported: Any,
+) -> Intent:
+ """Capture squidpy spatial_segment kwargs into an Intent.
+
+ Accepts AnnData or SpatialData; routes through sdata-plot's render_labels at
+ execution time. On SpatialData input, ``labels_layer`` / ``image_layer`` /
+ ``table`` disambiguate the element to render when a coordinate system holds
+ more than one candidate (ignored for AnnData input).
+ """
+ if unsupported:
+ offenders = sorted(unsupported)
+ raise NotImplementedError(f"spatial_segment via spatialdata-plot does not yet support kwargs: {offenders}.")
+ legend_loc = _downgrade_on_data_legend(legend_loc)
+
+ if seg_contourpx == 1:
+ raise ValueError("seg_contourpx=1 is rejected by spatialdata-plot v0.3.4 (PR #645). Use >= 2 or None.")
+
+ color_tuple = _normalize_color(color)
+ source = _make_source(
+ data,
+ shapes_layer=None,
+ labels_layer=labels_layer,
+ points_layer=None,
+ image_layer=image_layer,
+ table=table,
+ )
+ library_ids = source.library_ids(library_key, library_id)
+
+ crop_per_lib = _per_library(crop_coord, library_ids, "crop_coord")
+ scalebar_dx_per_lib = _per_library(scalebar_dx, library_ids, "scalebar_dx")
+ scalebar_units_per_lib = _per_library(scalebar_units, library_ids, "scalebar_units")
+ size_per_lib = tuple(None for _ in library_ids) # spatial_segment has no size kwarg
+
+ panels = _expand_panels(
+ library_ids,
+ color_tuple,
+ library_first,
+ crop_per_lib,
+ scalebar_dx_per_lib,
+ scalebar_units_per_lib,
+ size_per_lib,
+ title,
+ )
+
+ ax_seq = _validate_ax(ax, len(panels))
+
+ data_intent = DataIntent(
+ element_kind="labels",
+ needs_image=bool(img),
+ library_ids=library_ids,
+ library_key=library_key,
+ coordinate_system=spatial_key,
+ img_res_key=img_res_key if img else None,
+ img_channel=img_channel,
+ color=color_tuple,
+ use_raw=use_raw,
+ layer=layer,
+ alt_var=alt_var,
+ seg_cell_id=seg_cell_id,
+ scale_factor=scale_factor,
+ )
+
+ resolved_norm = _build_norm(vmin=vmin, vmax=vmax, vcenter=vcenter, norm=norm)
+ outline_alpha = 1.0 if seg_outline else 0.0
+ resolved_palette, palette_cmap, color_override, inferred_groups = _resolve_palette(palette)
+ resolved_cmap = palette_cmap if cmap is None else cmap
+ groups_tuple = _normalize_groups(groups) or inferred_groups
+ panels = _apply_color_override(panels, color_override, color_tuple)
+ panels = _assign_names(
+ panels,
+ source,
+ data_intent.element_kind,
+ needs_image=data_intent.needs_image,
+ )
+
+ render = RenderIntent(
+ cmap=resolved_cmap,
+ norm=resolved_norm,
+ palette=resolved_palette,
+ alpha=alpha,
+ na_color=na_color,
+ contour_px=seg_contourpx,
+ outline_alpha=outline_alpha,
+ groups=groups_tuple,
+ img_alpha=img_alpha,
+ img_cmap=img_cmap,
+ )
+
+ layout = LayoutIntent(
+ ncols=ncols,
+ library_first=library_first,
+ wspace=wspace,
+ hspace=hspace,
+ figsize=figsize,
+ dpi=dpi,
+ frameon=frameon,
+ return_ax=return_ax,
+ fig=fig,
+ ax=ax_seq,
+ )
+
+ post = PostRenderIntent(
+ axis_label=_normalize_axis_label(axis_label),
+ legend_loc=legend_loc,
+ legend_fontsize=legend_fontsize,
+ legend_fontweight=legend_fontweight,
+ legend_fontoutline=legend_fontoutline,
+ legend_na=legend_na,
+ colorbar=colorbar,
+ scalebar_params=scalebar_kwargs,
+ save=save,
+ )
+
+ return Intent(
+ mode="segment",
+ data=data_intent,
+ render=render,
+ layout=layout,
+ post=post,
+ panels=panels,
+ )
diff --git a/src/squidpy/pl/_sdata_delegation/_intent.py b/src/squidpy/pl/_sdata_delegation/_intent.py
new file mode 100644
index 000000000..6bad5ad06
--- /dev/null
+++ b/src/squidpy/pl/_sdata_delegation/_intent.py
@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any, Literal
+
+ElementKind = Literal["shapes", "labels", "points"]
+
+
+@dataclass(frozen=True, slots=True)
+class DataIntent:
+ element_kind: ElementKind = "shapes"
+ needs_image: bool = False
+ needs_graph: bool = False
+ library_ids: tuple[str, ...] = ()
+ library_key: str | None = None
+ coordinate_system: str | None = None
+ img_res_key: str | None = None
+ img_channel: int | tuple[int, ...] | None = None
+ color: tuple[str, ...] = ()
+ use_raw: bool | None = None
+ layer: str | None = None
+ alt_var: str | None = None
+ size_key: str | None = None
+ seg_cell_id: str | None = None
+ graph_layer: str | None = None
+ # Manual override of the image scale factor for the AnnData shim path; None means
+ # derive from uns[spatial][lib]['scalefactors']. Ignored on SpatialData input.
+ scale_factor: float | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class RenderIntent:
+ shape: str | None = None
+ cmap: Any = None
+ norm: Any = None
+ palette: Any = None
+ alpha: float = 1.0
+ na_color: Any = (0.0, 0.0, 0.0, 0.0)
+ groups: tuple[str, ...] | None = None
+ img_alpha: float | None = None
+ img_cmap: Any = None
+ contour_px: int | None = None
+ outline_alpha: float | None = None
+ outline: bool = False
+ outline_color: tuple[str, str] = ("black", "white")
+ outline_width: tuple[float, float] = (0.3, 0.05)
+ edges_width: float = 1.0
+ edges_color: Any = "grey"
+ edges_kwargs: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass(frozen=True, slots=True)
+class LayoutIntent:
+ ncols: int = 4
+ library_first: bool = True
+ wspace: float | None = None
+ hspace: float | None = None
+ figsize: tuple[float, float] | None = None
+ dpi: int | None = None
+ frameon: bool | None = None
+ return_ax: bool = False
+ fig: Any = None
+ ax: Any = None
+
+
+@dataclass(frozen=True, slots=True)
+class PostRenderIntent:
+ title: tuple[str, ...] | None = None
+ axis_label: tuple[str, ...] | None = None
+ legend_loc: str | None = "right margin"
+ legend_fontsize: Any = None
+ legend_fontweight: Any = "bold"
+ legend_fontoutline: int | None = None
+ legend_na: bool = True
+ colorbar: bool = True
+ scalebar_params: dict[str, Any] | None = None
+ save: str | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class PanelIntent:
+ library_id: str
+ color: str | None
+ size: float | None = None
+ crop_coord: tuple[float, float, float, float] | None = None
+ scalebar_dx: float | None = None
+ scalebar_units: str | None = None
+ title: str | None = None
+ # Resolved SpatialData element names for this panel. Populated at capture time by the
+ # source (shim names for AnnData input, real element names for SpatialData input) so
+ # _render never derives names itself.
+ element_name: str | None = None
+ image_name: str | None = None
+ table_name: str | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class Intent:
+ mode: str
+ data: DataIntent
+ render: RenderIntent
+ layout: LayoutIntent
+ post: PostRenderIntent
+ panels: tuple[PanelIntent, ...]
diff --git a/src/squidpy/pl/_sdata_delegation/_render.py b/src/squidpy/pl/_sdata_delegation/_render.py
new file mode 100644
index 000000000..b96c75eaf
--- /dev/null
+++ b/src/squidpy/pl/_sdata_delegation/_render.py
@@ -0,0 +1,205 @@
+from __future__ import annotations
+
+import math
+from collections.abc import Sequence
+from typing import Any
+
+import matplotlib.pyplot as plt
+import spatialdata_plot # noqa: F401 -- registers .pl accessor
+from matplotlib.axes import Axes
+from matplotlib.figure import Figure
+from spatialdata import SpatialData
+
+from squidpy.pl._utils import save_fig
+
+from ._intent import Intent, PanelIntent
+
+# edges_kwargs keys we forward into render_graph; anything else is rejected (no silent drop).
+_ALLOWED_EDGE_KWARGS = frozenset({"edge_alpha", "linestyle", "weight_key"})
+
+
+def _make_grid(
+ n_panels: int,
+ ncols: int,
+ figsize: tuple[float, float] | None,
+ dpi: int | None,
+ fig: Figure | None,
+ ax: tuple[Axes, ...] | None,
+) -> tuple[Figure, list[Axes]]:
+ if ax is not None:
+ axes = list(ax)
+ owning_fig = fig if fig is not None else axes[0].get_figure()
+ return owning_fig, axes
+ cols = min(ncols, n_panels)
+ rows = math.ceil(n_panels / cols)
+ if figsize is None:
+ figsize = (4.0 * cols, 4.0 * rows)
+ if fig is None:
+ new_fig, new_axes = plt.subplots(rows, cols, figsize=figsize, dpi=dpi, squeeze=False)
+ else:
+ new_fig = fig
+ new_axes = fig.subplots(rows, cols, squeeze=False)
+ flat = list(new_axes.ravel())
+ for blank in flat[n_panels:]:
+ blank.set_axis_off()
+ return new_fig, flat[:n_panels]
+
+
+def _color_kwargs(panel: PanelIntent, intent: Intent) -> dict[str, Any]:
+ """Build the color/cmap/palette/groups/table_* kwargs shared across render_* calls."""
+ return {
+ "color": panel.color,
+ "palette": intent.render.palette,
+ "cmap": intent.render.cmap,
+ "norm": intent.render.norm,
+ "na_color": intent.render.na_color,
+ "groups": list(intent.render.groups) if intent.render.groups else None,
+ "table_name": panel.table_name,
+ "table_layer": intent.data.layer,
+ "gene_symbols": intent.data.alt_var,
+ }
+
+
+def _draw_panel(chain: SpatialData, panel: PanelIntent, intent: Intent) -> SpatialData:
+ """Compose render_* calls for one panel.
+
+ Z-order: render_images (bottom) -> render_graph -> render_shapes / render_labels /
+ render_points (top). Edges drawn before points so points sit on top, matching
+ squidpy's legacy order at _spatial.py:267-277.
+ """
+ color_kw = _color_kwargs(panel, intent)
+
+ if intent.data.needs_image:
+ img_kw: dict[str, Any] = {}
+ if intent.render.img_alpha is not None:
+ img_kw["alpha"] = intent.render.img_alpha
+ if intent.render.img_cmap is not None:
+ img_kw["cmap"] = intent.render.img_cmap
+ if intent.data.img_channel is not None:
+ img_kw["channel"] = intent.data.img_channel
+ chain = chain.pl.render_images(panel.image_name, **img_kw)
+
+ kind = intent.data.element_kind
+
+ if intent.data.needs_graph and intent.data.graph_layer is not None:
+ element_name = panel.element_name
+ unknown = set(intent.render.edges_kwargs) - _ALLOWED_EDGE_KWARGS
+ if unknown:
+ raise NotImplementedError(
+ f"edges_kwargs keys not supported: {sorted(unknown)}. Allowed keys: {sorted(_ALLOWED_EDGE_KWARGS)}."
+ )
+ chain = chain.pl.render_graph(
+ element_name,
+ color=intent.render.edges_color if isinstance(intent.render.edges_color, str) else "grey",
+ connectivity_key=intent.data.graph_layer,
+ edge_width=intent.render.edges_width,
+ table_name=panel.table_name,
+ **intent.render.edges_kwargs,
+ )
+
+ if kind == "shapes":
+ kw = dict(color_kw)
+ kw["shape"] = intent.render.shape
+ kw["fill_alpha"] = intent.render.alpha
+ if panel.size is not None:
+ kw["scale"] = float(panel.size)
+ if intent.render.outline:
+ bg_color, gap_color = intent.render.outline_color
+ bg_width, gap_width = intent.render.outline_width
+ # sdata-plot v0.3.4 tuple-outline: nested rings rendered in one pass.
+ kw["outline_color"] = (bg_color, gap_color)
+ kw["outline_width"] = (bg_width + gap_width, gap_width)
+ kw["outline_alpha"] = (1.0, 1.0)
+ chain = chain.pl.render_shapes(panel.element_name, **kw)
+ elif kind == "labels":
+ kw = dict(color_kw)
+ kw["fill_alpha"] = intent.render.alpha
+ kw["contour_px"] = intent.render.contour_px
+ kw["outline_alpha"] = intent.render.outline_alpha
+ chain = chain.pl.render_labels(panel.element_name, **kw)
+ else: # points
+ kw = dict(color_kw)
+ kw["alpha"] = intent.render.alpha
+ chain = chain.pl.render_points(panel.element_name, **kw)
+
+ return chain
+
+
+def _apply_post(panel: PanelIntent, intent: Intent, ax: Axes) -> None:
+ if panel.title is not None:
+ ax.set_title(panel.title)
+ if intent.layout.frameon is False:
+ ax.set_frame_on(False)
+ # axis_label has no native show() kwarg (upstream scverse/spatialdata-plot#763);
+ # apply post-render. A bare str set only the x-axis; a pair sets both.
+ if intent.post.axis_label is not None:
+ labels = intent.post.axis_label
+ if len(labels) >= 1 and labels[0] is not None:
+ ax.set_xlabel(labels[0])
+ if len(labels) >= 2 and labels[1] is not None:
+ ax.set_ylabel(labels[1])
+ if panel.crop_coord is not None:
+ x0, x1, y0, y1 = panel.crop_coord
+ ax.set_xlim(x0, x1)
+ ax.set_ylim(y1, y0) # image y-axis is top-down
+
+
+def _show_kwargs(intent: Intent, panel: PanelIntent) -> dict[str, Any]:
+ """Legend / colorbar / scalebar params forwarded into the per-panel show()."""
+ post = intent.post
+ kw: dict[str, Any] = {
+ "legend_loc": post.legend_loc,
+ "na_in_legend": post.legend_na,
+ "colorbar": post.colorbar,
+ }
+ if post.legend_fontsize is not None:
+ kw["legend_fontsize"] = post.legend_fontsize
+ if post.legend_fontweight is not None:
+ kw["legend_fontweight"] = post.legend_fontweight
+ if post.legend_fontoutline is not None:
+ kw["legend_fontoutline"] = post.legend_fontoutline
+ if panel.scalebar_dx is not None:
+ kw["scalebar_dx"] = panel.scalebar_dx
+ if panel.scalebar_units is not None:
+ kw["scalebar_units"] = panel.scalebar_units
+ if post.scalebar_params is not None:
+ kw["scalebar_params"] = post.scalebar_params
+ return kw
+
+
+def _render_from_intent(sdata: SpatialData, intent: Intent) -> Figure | Axes | Sequence[Axes] | None:
+ panels = intent.panels
+ owning_fig, axes = _make_grid(
+ n_panels=len(panels),
+ ncols=intent.layout.ncols,
+ figsize=intent.layout.figsize,
+ dpi=intent.layout.dpi,
+ fig=intent.layout.fig,
+ ax=intent.layout.ax,
+ )
+
+ # panel spacing only when we own the grid (no user-supplied axes)
+ if intent.layout.ax is None:
+ spacing = {
+ k: v for k, v in (("wspace", intent.layout.wspace), ("hspace", intent.layout.hspace)) if v is not None
+ }
+ if spacing:
+ owning_fig.subplots_adjust(**spacing)
+
+ for panel, ax in zip(panels, axes, strict=True):
+ chain = _draw_panel(sdata, panel, intent)
+ show_kw: dict[str, Any] = {
+ "ax": ax,
+ "coordinate_systems": panel.library_id,
+ "return_ax": False,
+ }
+ show_kw.update(_show_kwargs(intent, panel))
+ chain.pl.show(**show_kw)
+ _apply_post(panel, intent, ax)
+
+ if intent.post.save is not None:
+ save_fig(owning_fig, path=intent.post.save)
+
+ if intent.layout.return_ax:
+ return axes[0] if len(axes) == 1 else axes
+ return owning_fig
diff --git a/src/squidpy/pl/_sdata_delegation/_source.py b/src/squidpy/pl/_sdata_delegation/_source.py
new file mode 100644
index 000000000..40443d400
--- /dev/null
+++ b/src/squidpy/pl/_sdata_delegation/_source.py
@@ -0,0 +1,141 @@
+"""Input-source abstraction for the delegation backend.
+
+Capture is almost input-agnostic: its only coupling to the concrete input is
+(a) resolving the list of libraries and (b) naming the SpatialData elements each
+panel renders. A source encapsulates exactly those two concerns so one capture path
+serves both AnnData (via the transient-sdata shim) and native SpatialData input.
+"""
+
+from __future__ import annotations
+
+from typing import Protocol
+
+from anndata import AnnData
+from spatialdata import SpatialData
+
+from squidpy._constants._pkg_constants import Key
+
+from ._adapter import _image_name, _labels_name, _points_name, _shapes_name, _table_name
+from ._intent import ElementKind
+
+
+class _Source(Protocol):
+ def library_ids(self, library_key: str | None, library_id: object) -> tuple[str, ...]: ...
+ def element_name(self, library_id: str, kind: ElementKind) -> str: ...
+ def image_name(self, library_id: str) -> str | None: ...
+ def table_name(self, library_id: str) -> str | None: ...
+
+
+class _AnnDataSource:
+ """Names follow the transient-sdata shim convention (see _adapter)."""
+
+ def __init__(self, adata: AnnData) -> None:
+ self.adata = adata
+
+ def library_ids(self, library_key: str | None, library_id: object) -> tuple[str, ...]:
+ if library_id is not None:
+ return (library_id,) if isinstance(library_id, str) else tuple(library_id)
+ if library_key is not None:
+ return tuple(map(str, self.adata.obs[library_key].cat.categories))
+ if Key.uns.spatial in self.adata.uns:
+ return tuple(self.adata.uns[Key.uns.spatial].keys())
+ raise ValueError("No library_id or library_key provided and no 'spatial' key in adata.uns.")
+
+ def element_name(self, library_id: str, kind: ElementKind) -> str:
+ return {"shapes": _shapes_name, "points": _points_name, "labels": _labels_name}[kind](library_id)
+
+ def image_name(self, library_id: str) -> str | None:
+ return _image_name(library_id)
+
+ def table_name(self, library_id: str) -> str | None:
+ return _table_name(library_id)
+
+
+class _SpatialDataSource:
+ """Resolve element/table names from a user's SpatialData.
+
+ Libraries are coordinate systems (subset by ``library_id``). Within a coordinate
+ system an element type is auto-resolved when unique; otherwise the caller must
+ disambiguate with the matching ``*_layer`` kwarg, else a ValueError lists the
+ candidates (mirrors scanpy's ``layer=`` ergonomics).
+ """
+
+ def __init__(
+ self,
+ sdata: SpatialData,
+ *,
+ shapes_layer: str | None = None,
+ labels_layer: str | None = None,
+ points_layer: str | None = None,
+ image_layer: str | None = None,
+ table: str | None = None,
+ ) -> None:
+ self.sdata = sdata
+ self._explicit: dict[str, str | None] = {
+ "shapes": shapes_layer,
+ "labels": labels_layer,
+ "points": points_layer,
+ "images": image_layer,
+ }
+ self._table = table
+
+ def library_ids(self, library_key: str | None, library_id: object) -> tuple[str, ...]:
+ if library_key is not None:
+ raise ValueError(
+ "`library_key` is AnnData-only. On SpatialData input, libraries are coordinate "
+ "systems; select them with `library_id`."
+ )
+ systems = tuple(self.sdata.coordinate_systems)
+ if library_id is None:
+ return systems
+ wanted = (library_id,) if isinstance(library_id, str) else tuple(map(str, library_id))
+ missing = [w for w in wanted if w not in systems]
+ if missing:
+ raise ValueError(f"Coordinate system(s) {missing} not in SpatialData; available: {list(systems)}.")
+ return wanted
+
+ def _resolve(self, library_id: str, container: str, *, required: bool) -> str | None:
+ sub = self.sdata.filter_by_coordinate_system(library_id)
+ keys = list(getattr(sub, container))
+ explicit = self._explicit.get(container)
+ if explicit is not None:
+ if explicit not in keys:
+ raise ValueError(
+ f"{container} layer {explicit!r} not found in coordinate system {library_id!r}; available: {keys}."
+ )
+ return explicit
+ if len(keys) == 1:
+ return keys[0]
+ if not keys:
+ if required:
+ raise ValueError(f"No {container} element in coordinate system {library_id!r}.")
+ return None
+ raise ValueError(
+ f"Multiple {container} elements in coordinate system {library_id!r}: {keys}. "
+ f"Disambiguate with the matching *_layer kwarg."
+ )
+
+ def element_name(self, library_id: str, kind: ElementKind) -> str:
+ # ElementKind values ("shapes"/"labels"/"points") are exactly the SpatialData
+ # element-container attribute names.
+ name = self._resolve(library_id, kind, required=True)
+ assert name is not None # required=True guarantees non-None
+ return name
+
+ def image_name(self, library_id: str) -> str | None:
+ return self._resolve(library_id, "images", required=False)
+
+ def table_name(self, library_id: str) -> str | None:
+ if self._table is not None:
+ if self._table not in self.sdata.tables:
+ raise ValueError(f"table {self._table!r} not found; available: {list(self.sdata.tables)}.")
+ return self._table
+ # find a table annotating any element in this coordinate system
+ sub = self.sdata.filter_by_coordinate_system(library_id)
+ element_names = set(sub.shapes) | set(sub.labels) | set(sub.points)
+ for tname, tbl in self.sdata.tables.items():
+ region = tbl.uns.get("spatialdata_attrs", {}).get("region")
+ regions = {region} if isinstance(region, str) else set(region or ())
+ if regions & element_names:
+ return tname
+ return None
diff --git a/src/squidpy/pl/_spatial.py b/src/squidpy/pl/_spatial.py
index 1c2042f0d..00fd4e1ab 100644
--- a/src/squidpy/pl/_spatial.py
+++ b/src/squidpy/pl/_spatial.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import itertools
+import os
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from types import MappingProxyType
@@ -41,6 +42,17 @@
from squidpy.pl._utils import sanitize_anndata, save_fig
+def _use_sdata_plot_backend() -> bool:
+ """Return True when the spatialdata-plot delegation backend should be used.
+
+ Toggled by the SQUIDPY_USE_SDATAPLOT environment variable (any non-empty,
+ non-falsy value enables it). Off by default so existing behavior is
+ unchanged. Used during the migration window to A/B the new pipeline
+ against the legacy _spatial_plot implementation.
+ """
+ return os.environ.get("SQUIDPY_USE_SDATAPLOT", "").lower() in {"1", "true", "yes", "on"}
+
+
@d.get_sections(base="spatial_plot", sections=["Returns"])
@d.get_extended_summary(base="spatial_plot")
@d.dedent
@@ -433,6 +445,10 @@ def spatial_scatter(
-------
%(spatial_plot.returns)s
"""
+ if _use_sdata_plot_backend():
+ from squidpy.pl._sdata_delegation import _spatial_scatter_via_sdata_plot
+
+ return _spatial_scatter_via_sdata_plot(adata, shape=shape, **kwargs)
return _spatial_plot(adata, shape=shape, seg=None, seg_key=None, **kwargs)
@@ -477,6 +493,17 @@ def spatial_segment(
-------
%(spatial_plot.returns)s
"""
+ if _use_sdata_plot_backend():
+ from squidpy.pl._sdata_delegation import _spatial_segment_via_sdata_plot
+
+ return _spatial_segment_via_sdata_plot(
+ adata,
+ seg_cell_id=seg_cell_id,
+ seg_key=seg_key,
+ seg_contourpx=seg_contourpx,
+ seg_outline=seg_outline,
+ **kwargs,
+ )
return _spatial_plot(
adata,
seg=seg,
diff --git a/tests/plotting/conftest.py b/tests/plotting/conftest.py
new file mode 100644
index 000000000..ef4303f40
--- /dev/null
+++ b/tests/plotting/conftest.py
@@ -0,0 +1,29 @@
+"""Plotting test conftest.
+
+When SQUIDPY_USE_SDATAPLOT=1 is set, the legacy reference-image suite in
+test_spatial_static.py compares against baselines that were generated by the
+legacy matplotlib renderer. The sdata-plot delegation produces different
+pixels by design, so the comparisons fail noisily. Skip them under the flag
+and point users at the new-path suite.
+"""
+
+from __future__ import annotations
+
+import os
+
+import pytest
+
+
+def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
+ if os.environ.get("SQUIDPY_USE_SDATAPLOT", "").lower() not in {"1", "true", "yes", "on"}:
+ return
+ skip_marker = pytest.mark.skip(
+ reason=(
+ "Skipped under SQUIDPY_USE_SDATAPLOT=1: legacy reference-image baselines target "
+ "the matplotlib renderer. Use tests/plotting/test_spatial_scatter_sdataplot.py "
+ "for the delegation pipeline."
+ )
+ )
+ for item in items:
+ if "test_spatial_static.py" in str(item.fspath) and "TestSpatialStatic" in item.nodeid:
+ item.add_marker(skip_marker)
diff --git a/tests/plotting/test_spatial_scatter_sdataplot.py b/tests/plotting/test_spatial_scatter_sdataplot.py
new file mode 100644
index 000000000..5a3006dcb
--- /dev/null
+++ b/tests/plotting/test_spatial_scatter_sdataplot.py
@@ -0,0 +1,564 @@
+"""Smoke tests for the spatialdata-plot delegation pipeline.
+
+Covers the three happy paths identified in plans/delegate-plots-to-sdata-plot.md:
+- Path 1: Visium spots over H&E, categorical coloring, single + multi-library.
+- Path 2: Visium spots over H&E, continuous gene-expression coloring, N-gene grids.
+- Path 3: Segmentation masks colored by cell type (MIBI-TOF-style).
+"""
+
+from __future__ import annotations
+
+import matplotlib
+import matplotlib.pyplot as plt
+import pytest
+from anndata import AnnData
+from matplotlib.figure import Figure
+
+from squidpy.pl._sdata_delegation import (
+ _spatial_scatter_via_sdata_plot,
+ _spatial_segment_via_sdata_plot,
+)
+from squidpy.pl._sdata_delegation._capture import (
+ capture_scatter_intent,
+ capture_segment_intent,
+)
+
+matplotlib.use("Agg")
+
+
+@pytest.fixture()
+def adata_hne_with_cluster(adata_hne: AnnData) -> AnnData:
+ a = adata_hne.copy()
+ a.obs["cluster_path1"] = (a.obs["array_col"] > a.obs["array_col"].median()).astype(str).astype("category")
+ return a
+
+
+@pytest.fixture()
+def adata_hne_concat_with_cluster(adata_hne_concat: AnnData) -> AnnData:
+ a = adata_hne_concat.copy()
+ a.obs["cluster_path1"] = (a.obs["array_col"] > a.obs["array_col"].median()).astype(str).astype("category")
+ return a
+
+
+class TestCaptureIntent:
+ def test_single_library_resolved_from_uns(self, adata_hne_with_cluster: AnnData) -> None:
+ intent = capture_scatter_intent(adata_hne_with_cluster, color="cluster_path1")
+ assert intent.data.library_ids == ("V1_Adult_Mouse_Brain",)
+ assert len(intent.panels) == 1
+ assert intent.panels[0].color == "cluster_path1"
+ assert intent.data.element_kind == "shapes"
+ assert intent.data.needs_image is True
+
+ def test_multi_library_via_library_key(self, adata_hne_concat_with_cluster: AnnData) -> None:
+ intent = capture_scatter_intent(adata_hne_concat_with_cluster, color="cluster_path1", library_key="library_id")
+ assert set(intent.data.library_ids) == {"V1_Adult_Mouse_Brain", "V2_Adult_Mouse_Brain"}
+ assert len(intent.panels) == 2
+
+ def test_no_color_is_allowed(self, adata_hne_with_cluster: AnnData) -> None:
+ intent = capture_scatter_intent(adata_hne_with_cluster)
+ assert intent.panels[0].color is None
+
+ def test_multi_color_expands_panels(self, adata_hne_with_cluster: AnnData) -> None:
+ intent = capture_scatter_intent(adata_hne_with_cluster, color=["a", "b", "c"])
+ assert len(intent.panels) == 3
+ assert tuple(p.color for p in intent.panels) == ("a", "b", "c")
+
+ def test_panel_iteration_order_library_first(self, adata_hne_concat_with_cluster: AnnData) -> None:
+ intent = capture_scatter_intent(
+ adata_hne_concat_with_cluster,
+ color=["g1", "g2"],
+ library_key="library_id",
+ library_first=True,
+ )
+ assert len(intent.panels) == 4
+ # library_first=True: V1, V1, V2, V2 with colors g1, g2, g1, g2
+ first_lib_colors = [p.color for p in intent.panels if p.library_id == intent.data.library_ids[0]]
+ assert first_lib_colors == ["g1", "g2"]
+
+ def test_panel_iteration_order_color_first(self, adata_hne_concat_with_cluster: AnnData) -> None:
+ intent = capture_scatter_intent(
+ adata_hne_concat_with_cluster,
+ color=["g1", "g2"],
+ library_key="library_id",
+ library_first=False,
+ )
+ assert len(intent.panels) == 4
+ # library_first=False: g1/V1, g1/V2, g2/V1, g2/V2
+ first_two = [(p.library_id, p.color) for p in intent.panels[:2]]
+ assert {p[1] for p in first_two} == {"g1"}
+
+ def test_unsupported_kwarg_rejected(self, adata_hne_with_cluster: AnnData) -> None:
+ with pytest.raises(NotImplementedError, match="does not yet support"):
+ capture_scatter_intent(adata_hne_with_cluster, color="cluster_path1", some_future_kwarg=True)
+
+ def test_legend_loc_on_data_deprecated(self, adata_hne_with_cluster: AnnData) -> None:
+ with pytest.warns(DeprecationWarning, match="on data"):
+ capture_scatter_intent(adata_hne_with_cluster, color="cluster_path1", legend_loc="on data")
+
+ def test_size_per_library_sequence(self, adata_hne_concat_with_cluster: AnnData) -> None:
+ intent = capture_scatter_intent(
+ adata_hne_concat_with_cluster,
+ color="cluster_path1",
+ library_key="library_id",
+ size=[0.5, 1.5],
+ )
+ sizes_by_lib = {p.library_id: p.size for p in intent.panels}
+ assert sizes_by_lib == {"V1_Adult_Mouse_Brain": 0.5, "V2_Adult_Mouse_Brain": 1.5}
+
+ def test_size_scalar_broadcasts(self, adata_hne_concat_with_cluster: AnnData) -> None:
+ intent = capture_scatter_intent(
+ adata_hne_concat_with_cluster,
+ color="cluster_path1",
+ library_key="library_id",
+ size=0.75,
+ )
+ assert all(p.size == 0.75 for p in intent.panels)
+
+ def test_size_wrong_length_rejected(self, adata_hne_concat_with_cluster: AnnData) -> None:
+ with pytest.raises(ValueError, match="size"):
+ capture_scatter_intent(
+ adata_hne_concat_with_cluster,
+ color="cluster_path1",
+ library_key="library_id",
+ size=[0.5, 0.5, 0.5],
+ )
+
+ def test_palette_as_colormap_routes_to_cmap(self, adata_hne_with_cluster: AnnData) -> None:
+ from matplotlib.colors import ListedColormap
+
+ palette = ListedColormap(["#ff0000", "#00ff00", "#0000ff"])
+ intent = capture_scatter_intent(adata_hne_with_cluster, color="cluster_path1", palette=palette)
+ # Colormap routes to cmap; palette stays None so sdata-plot doesn't require groups.
+ assert intent.render.palette is None
+ assert isinstance(intent.render.cmap, ListedColormap)
+
+ def test_palette_as_string_list_wraps_as_cmap(self, adata_hne_with_cluster: AnnData) -> None:
+ from matplotlib.colors import ListedColormap
+
+ intent = capture_scatter_intent(adata_hne_with_cluster, color="cluster_path1", palette=["#aabbcc", "#ddeeff"])
+ assert intent.render.palette is None
+ assert isinstance(intent.render.cmap, ListedColormap)
+
+ def test_palette_dict_keeps_palette(self, adata_hne_with_cluster: AnnData) -> None:
+ palette = {"True": "#ff0000", "False": "#0000ff"}
+ intent = capture_scatter_intent(adata_hne_with_cluster, color="cluster_path1", palette=palette)
+ assert intent.render.palette == palette
+ assert intent.render.groups == ("True", "False")
+
+ def test_vmin_vmax_folded_into_norm(self, adata_hne_with_cluster: AnnData) -> None:
+ from matplotlib.colors import Normalize
+
+ intent = capture_scatter_intent(adata_hne_with_cluster, color="cluster_path1", vmin=0.0, vmax=5.0)
+ assert isinstance(intent.render.norm, Normalize)
+ assert intent.render.norm.vmin == 0.0
+ assert intent.render.norm.vmax == 5.0
+
+ def test_vcenter_uses_twoslope(self, adata_hne_with_cluster: AnnData) -> None:
+ from matplotlib.colors import TwoSlopeNorm
+
+ intent = capture_scatter_intent(adata_hne_with_cluster, color="cluster_path1", vmin=-1.0, vmax=1.0, vcenter=0.0)
+ assert isinstance(intent.render.norm, TwoSlopeNorm)
+
+ def test_norm_and_vmin_conflict_rejected(self, adata_hne_with_cluster: AnnData) -> None:
+ from matplotlib.colors import Normalize
+
+ with pytest.raises(ValueError, match="not both"):
+ capture_scatter_intent(adata_hne_with_cluster, color="cluster_path1", norm=Normalize(0, 1), vmin=0)
+
+ def test_shape_none_routes_to_points(self, adata_hne_with_cluster: AnnData) -> None:
+ intent = capture_scatter_intent(adata_hne_with_cluster, color="cluster_path1", shape=None)
+ assert intent.data.element_kind == "points"
+
+
+class TestRender:
+ def test_single_library_renders_one_panel(self, adata_hne_with_cluster: AnnData) -> None:
+ fig = _spatial_scatter_via_sdata_plot(adata_hne_with_cluster, color="cluster_path1")
+ assert isinstance(fig, Figure)
+ assert len(fig.axes) >= 1 # at least the plot axis; legend axes are extra
+ plt.close(fig)
+
+ def test_multi_library_renders_two_panels(self, adata_hne_concat_with_cluster: AnnData) -> None:
+ fig = _spatial_scatter_via_sdata_plot(
+ adata_hne_concat_with_cluster, color="cluster_path1", library_key="library_id"
+ )
+ assert isinstance(fig, Figure)
+ panel_axes = [ax for ax in fig.axes if ax.get_subplotspec() is not None]
+ assert len(panel_axes) == 2
+ plt.close(fig)
+
+ def test_no_image_renders_only_shapes(self, adata_hne_with_cluster: AnnData) -> None:
+ fig = _spatial_scatter_via_sdata_plot(adata_hne_with_cluster, color="cluster_path1", img=False)
+ assert isinstance(fig, Figure)
+ plt.close(fig)
+
+ def test_return_ax_returns_axes(self, adata_hne_with_cluster: AnnData) -> None:
+ result = _spatial_scatter_via_sdata_plot(adata_hne_with_cluster, color="cluster_path1", return_ax=True)
+ from matplotlib.axes import Axes
+
+ assert isinstance(result, Axes)
+ plt.close("all")
+
+ def test_palette_dict_applied(self, adata_hne_concat_with_cluster: AnnData) -> None:
+ palette = {"True": "#ff0000", "False": "#0000ff"}
+ fig = _spatial_scatter_via_sdata_plot(
+ adata_hne_concat_with_cluster,
+ color="cluster_path1",
+ library_key="library_id",
+ palette=palette,
+ )
+ assert isinstance(fig, Figure)
+ plt.close(fig)
+
+
+class TestConnectivityEdges:
+ @pytest.fixture()
+ def adata_hne_with_neighbors(self, adata_hne: AnnData) -> AnnData:
+ from squidpy.gr import spatial_neighbors
+
+ a = adata_hne.copy()
+ spatial_neighbors(a)
+ a.obs["cluster_path1"] = (a.obs["array_col"] > a.obs["array_col"].median()).astype(str).astype("category")
+ return a
+
+ def test_capture_sets_needs_graph(self, adata_hne_with_neighbors: AnnData) -> None:
+ intent = capture_scatter_intent(
+ adata_hne_with_neighbors, color="cluster_path1", connectivity_key="spatial_connectivities"
+ )
+ assert intent.data.needs_graph is True
+ assert intent.data.graph_layer == "spatial_connectivities"
+
+ def test_no_connectivity_means_no_graph(self, adata_hne_with_neighbors: AnnData) -> None:
+ intent = capture_scatter_intent(adata_hne_with_neighbors, color="cluster_path1")
+ assert intent.data.needs_graph is False
+
+ def test_edges_render_single_library(self, adata_hne_with_neighbors: AnnData) -> None:
+ fig = _spatial_scatter_via_sdata_plot(
+ adata_hne_with_neighbors,
+ color="cluster_path1",
+ connectivity_key="spatial_connectivities",
+ img=False,
+ )
+ assert isinstance(fig, Figure)
+ plt.close(fig)
+
+ def test_edges_with_custom_width_color(self, adata_hne_with_neighbors: AnnData) -> None:
+ fig = _spatial_scatter_via_sdata_plot(
+ adata_hne_with_neighbors,
+ color="cluster_path1",
+ connectivity_key="spatial_connectivities",
+ edges_width=2.0,
+ edges_color="red",
+ img=False,
+ )
+ assert isinstance(fig, Figure)
+ plt.close(fig)
+
+
+class TestPath2Continuous:
+ def test_single_gene_renders(self, adata_hne: AnnData) -> None:
+ gene = adata_hne.var_names[0]
+ fig = _spatial_scatter_via_sdata_plot(adata_hne, color=gene, cmap="viridis")
+ assert isinstance(fig, Figure)
+ plt.close(fig)
+
+ def test_multi_gene_grid_panels(self, adata_hne: AnnData) -> None:
+ genes = list(adata_hne.var_names[:3])
+ fig = _spatial_scatter_via_sdata_plot(adata_hne, color=genes, cmap="viridis")
+ assert isinstance(fig, Figure)
+ plot_axes = [ax for ax in fig.axes if ax.get_subplotspec() is not None]
+ assert len(plot_axes) == 3
+ plt.close(fig)
+
+ def test_multi_gene_multi_library_grid(self, adata_hne_concat: AnnData) -> None:
+ genes = list(adata_hne_concat.var_names[:2])
+ fig = _spatial_scatter_via_sdata_plot(adata_hne_concat, color=genes, library_key="library_id", cmap="viridis")
+ assert isinstance(fig, Figure)
+ plot_axes = [ax for ax in fig.axes if ax.get_subplotspec() is not None]
+ assert len(plot_axes) == 4 # 2 libraries x 2 genes
+ plt.close(fig)
+
+ def test_vmin_vmax_applied_at_render(self, adata_hne: AnnData) -> None:
+ gene = adata_hne.var_names[0]
+ fig = _spatial_scatter_via_sdata_plot(adata_hne, color=gene, vmin=0.0, vmax=2.0)
+ assert isinstance(fig, Figure)
+ plt.close(fig)
+
+ def test_layer_passthrough(self, adata_hne: AnnData) -> None:
+ a = adata_hne.copy()
+ a.layers["scaled"] = a.X.copy()
+ gene = a.var_names[0]
+ fig = _spatial_scatter_via_sdata_plot(a, color=gene, layer="scaled")
+ assert isinstance(fig, Figure)
+ plt.close(fig)
+
+
+class TestPath3Segmentation:
+ @pytest.fixture()
+ def mibitof(self) -> AnnData:
+ import squidpy as sq
+
+ # Function-scoped + copy so tests that mutate obs (e.g. adding _sq_region via the
+ # adapter) don't leak state into siblings.
+ return sq.datasets.mibitof().copy()
+
+ def test_capture_requires_seg_cell_id(self, mibitof: AnnData) -> None:
+ with pytest.raises(TypeError):
+ capture_segment_intent(mibitof) # type: ignore[call-arg]
+
+ def test_capture_rejects_seg_contourpx_1(self, mibitof: AnnData) -> None:
+ with pytest.raises(ValueError, match="seg_contourpx=1"):
+ capture_segment_intent(mibitof, seg_cell_id="cell_id", seg_contourpx=1)
+
+ def test_capture_element_kind_is_labels(self, mibitof: AnnData) -> None:
+ intent = capture_segment_intent(mibitof, seg_cell_id="cell_id", color="Cluster")
+ assert intent.data.element_kind == "labels"
+ assert intent.data.seg_cell_id == "cell_id"
+
+ def test_single_library_segment_renders(self, mibitof: AnnData) -> None:
+ a = mibitof[mibitof.obs["library_id"] == "point16"].copy()
+ fig = _spatial_segment_via_sdata_plot(a, seg_cell_id="cell_id", color="Cluster")
+ assert isinstance(fig, Figure)
+ plt.close(fig)
+
+ def test_multi_library_segment_renders(self, mibitof: AnnData) -> None:
+ fig = _spatial_segment_via_sdata_plot(mibitof, seg_cell_id="cell_id", color="Cluster", library_key="library_id")
+ assert isinstance(fig, Figure)
+ plot_axes = [ax for ax in fig.axes if ax.get_subplotspec() is not None]
+ assert len(plot_axes) == 3
+ plt.close(fig)
+
+ def test_seg_contourpx_passthrough(self, mibitof: AnnData) -> None:
+ a = mibitof[mibitof.obs["library_id"] == "point16"].copy()
+ fig = _spatial_segment_via_sdata_plot(a, seg_cell_id="cell_id", color="Cluster", seg_contourpx=3)
+ assert isinstance(fig, Figure)
+ plt.close(fig)
+
+
+class TestWiredKwargs:
+ """M1: kwargs previously captured-then-dropped now produce an observable effect."""
+
+ def _panel_ax(self, fig: Figure):
+ return next(ax for ax in fig.axes if ax.get_subplotspec() is not None)
+
+ def test_save_writes_file(self, adata_hne_with_cluster: AnnData, tmp_path) -> None:
+ out = tmp_path / "scatter.png"
+ fig = _spatial_scatter_via_sdata_plot(adata_hne_with_cluster, color="cluster_path1", save=str(out))
+ assert out.exists() and out.stat().st_size > 0
+ plt.close(fig)
+
+ def test_colorbar_toggle(self, adata_hne: AnnData) -> None:
+ gene = adata_hne.var_names[0]
+ fig_on = _spatial_scatter_via_sdata_plot(adata_hne, color=gene, colorbar=True)
+ fig_off = _spatial_scatter_via_sdata_plot(adata_hne, color=gene, colorbar=False)
+ # continuous color: colorbar=True adds a dedicated colorbar axes, False does not.
+ assert len(fig_on.axes) > len(fig_off.axes)
+ plt.close(fig_on)
+ plt.close(fig_off)
+
+ def test_legend_toggle(self, adata_hne_with_cluster: AnnData) -> None:
+ fig_on = _spatial_scatter_via_sdata_plot(adata_hne_with_cluster, color="cluster_path1")
+ fig_off = _spatial_scatter_via_sdata_plot(adata_hne_with_cluster, color="cluster_path1", legend_loc=None)
+ assert self._panel_ax(fig_on).get_legend() is not None
+ assert self._panel_ax(fig_off).get_legend() is None
+ plt.close(fig_on)
+ plt.close(fig_off)
+
+ def test_axis_label_sets_labels(self, adata_hne_with_cluster: AnnData) -> None:
+ fig = _spatial_scatter_via_sdata_plot(adata_hne_with_cluster, color="cluster_path1", axis_label=["myX", "myY"])
+ ax = self._panel_ax(fig)
+ assert ax.get_xlabel() == "myX"
+ assert ax.get_ylabel() == "myY"
+ plt.close(fig)
+
+ def test_img_channel_and_alpha_render(self, adata_hne_with_cluster: AnnData) -> None:
+ fig = _spatial_scatter_via_sdata_plot(
+ adata_hne_with_cluster, color="cluster_path1", img_channel=0, img_alpha=0.5
+ )
+ assert isinstance(fig, Figure)
+ plt.close(fig)
+
+ def test_edges_kwargs_valid(self, adata_hne: AnnData) -> None:
+ from squidpy.gr import spatial_neighbors
+
+ a = adata_hne.copy()
+ spatial_neighbors(a)
+ a.obs["cluster_path1"] = (a.obs["array_col"] > a.obs["array_col"].median()).astype(str).astype("category")
+ fig = _spatial_scatter_via_sdata_plot(
+ a,
+ color="cluster_path1",
+ connectivity_key="spatial_connectivities",
+ edges_kwargs={"edge_alpha": 0.5},
+ img=False,
+ )
+ assert isinstance(fig, Figure)
+ plt.close(fig)
+
+ def test_edges_kwargs_unknown_raises(self, adata_hne: AnnData) -> None:
+ from squidpy.gr import spatial_neighbors
+
+ a = adata_hne.copy()
+ spatial_neighbors(a)
+ a.obs["cluster_path1"] = (a.obs["array_col"] > a.obs["array_col"].median()).astype(str).astype("category")
+ with pytest.raises(NotImplementedError, match="edges_kwargs"):
+ _spatial_scatter_via_sdata_plot(
+ a,
+ color="cluster_path1",
+ connectivity_key="spatial_connectivities",
+ edges_kwargs={"bogus_key": 1},
+ img=False,
+ )
+
+ def test_wspace_hspace_accepted(self, adata_hne_with_cluster: AnnData) -> None:
+ fig = _spatial_scatter_via_sdata_plot(
+ adata_hne_with_cluster, color=["cluster_path1", "cluster_path1"], wspace=0.4, hspace=0.3
+ )
+ assert isinstance(fig, Figure)
+ plt.close(fig)
+
+ def test_scale_factor_accepted_and_stored(self, adata_hne_with_cluster: AnnData) -> None:
+ # previously rejected via **unsupported; now an image-scalef override (V1)
+ intent = capture_scatter_intent(adata_hne_with_cluster, color="cluster_path1", scale_factor=2.0)
+ assert intent.data.scale_factor == 2.0
+ fig = _spatial_scatter_via_sdata_plot(adata_hne_with_cluster, color="cluster_path1", scale_factor=2.0)
+ assert isinstance(fig, Figure)
+ plt.close(fig)
+
+ def test_use_raw_default_matches_legacy(self, adata_hne: AnnData) -> None:
+ """Default (use_raw=None) plots raw counts when adata.raw exists, like legacy;
+ use_raw=False plots .X. Guards against a silent value-source change under the flag."""
+ assert adata_hne.raw is not None
+ gene = adata_hne.var_names[0]
+
+ def _color_vmax(fig: Figure) -> float:
+ vs = [
+ coll.norm.vmax
+ for ax in fig.axes
+ for coll in ax.collections
+ if coll.norm is not None and coll.norm.vmax is not None
+ ]
+ return max(vs)
+
+ fig_default = _spatial_scatter_via_sdata_plot(adata_hne, color=gene, img=False)
+ fig_x = _spatial_scatter_via_sdata_plot(adata_hne, color=gene, img=False, use_raw=False)
+ # raw counts have a larger dynamic range than normalized .X for this gene
+ assert _color_vmax(fig_default) > _color_vmax(fig_x)
+ plt.close(fig_default)
+ plt.close(fig_x)
+
+
+class TestSpatialDataNativeInput:
+ """M2/M3: render directly from a user's SpatialData, no AnnData shim."""
+
+ @pytest.fixture()
+ def sdata_visium_like(self):
+ import anndata as ad
+ import geopandas as gpd
+ import numpy as np
+ import pandas as pd
+ from shapely.geometry import Point
+ from spatialdata import SpatialData
+ from spatialdata.models import Image2DModel, ShapesModel, TableModel
+ from spatialdata.transformations import Identity, set_transformation
+
+ cs = "lib1"
+ n = 20
+ rng = np.random.default_rng(0)
+ xy = rng.uniform(5, 95, size=(n, 2))
+ spots = ShapesModel.parse(gpd.GeoDataFrame({"radius": np.full(n, 2.0)}, geometry=[Point(*p) for p in xy]))
+ set_transformation(spots, Identity(), to_coordinate_system=cs)
+ img = Image2DModel.parse(np.zeros((3, 100, 100), dtype=np.float32), dims=("c", "y", "x"))
+ set_transformation(img, Identity(), to_coordinate_system=cs)
+ obs = pd.DataFrame(
+ {
+ "region": pd.Categorical(["spots"] * n),
+ "inst": np.arange(n),
+ "ct": pd.Categorical(["a", "b"] * (n // 2)),
+ "score": rng.random(n),
+ }
+ )
+ adata = ad.AnnData(X=np.zeros((n, 3), dtype=np.float32), obs=obs)
+ tab = TableModel.parse(adata, region="spots", region_key="region", instance_key="inst")
+ return SpatialData(images={"he": img}, shapes={"spots": spots}, tables={"table": tab})
+
+ def test_categorical_renders(self, sdata_visium_like) -> None:
+ fig = _spatial_scatter_via_sdata_plot(sdata_visium_like, color="ct", library_id="lib1")
+ assert isinstance(fig, Figure)
+ plt.close(fig)
+
+ def test_continuous_renders(self, sdata_visium_like) -> None:
+ fig = _spatial_scatter_via_sdata_plot(sdata_visium_like, color="score", library_id="lib1", img=False)
+ assert isinstance(fig, Figure)
+ plt.close(fig)
+
+ def test_use_raw_rejected(self, sdata_visium_like) -> None:
+ with pytest.raises(ValueError, match="use_raw"):
+ _spatial_scatter_via_sdata_plot(sdata_visium_like, color="ct", library_id="lib1", use_raw=True)
+
+ def test_library_key_rejected(self, sdata_visium_like) -> None:
+ with pytest.raises(ValueError, match="library_key"):
+ _spatial_scatter_via_sdata_plot(sdata_visium_like, color="ct", library_key="foo")
+
+ def test_ambiguous_shapes_raises(self, sdata_visium_like) -> None:
+ # add a second shapes element to the same coordinate system -> ambiguous without shapes_layer
+ import geopandas as gpd
+ import numpy as np
+ from shapely.geometry import Point
+ from spatialdata.models import ShapesModel
+ from spatialdata.transformations import Identity, set_transformation
+
+ extra = ShapesModel.parse(
+ gpd.GeoDataFrame({"radius": np.full(3, 1.0)}, geometry=[Point(i, i) for i in range(3)])
+ )
+ set_transformation(extra, Identity(), to_coordinate_system="lib1")
+ sdata_visium_like.shapes["spots2"] = extra
+ with pytest.raises(ValueError, match="Multiple shapes"):
+ _spatial_scatter_via_sdata_plot(sdata_visium_like, color="ct", library_id="lib1", img=False)
+
+ def test_shapes_layer_disambiguates(self, sdata_visium_like) -> None:
+ import geopandas as gpd
+ import numpy as np
+ from shapely.geometry import Point
+ from spatialdata.models import ShapesModel
+ from spatialdata.transformations import Identity, set_transformation
+
+ extra = ShapesModel.parse(
+ gpd.GeoDataFrame({"radius": np.full(3, 1.0)}, geometry=[Point(i, i) for i in range(3)])
+ )
+ set_transformation(extra, Identity(), to_coordinate_system="lib1")
+ sdata_visium_like.shapes["spots2"] = extra
+ fig = _spatial_scatter_via_sdata_plot(
+ sdata_visium_like, color="ct", library_id="lib1", img=False, shapes_layer="spots"
+ )
+ assert isinstance(fig, Figure)
+ plt.close(fig)
+
+ def test_anndata_input_deprecated(self, adata_hne_with_cluster: AnnData) -> None:
+ with pytest.warns(DeprecationWarning, match="deprecated"):
+ fig = _spatial_scatter_via_sdata_plot(adata_hne_with_cluster, color="cluster_path1")
+ plt.close(fig)
+
+ @pytest.mark.parametrize("use_sdata", [False, True])
+ def test_render_parametrized_over_input_type(
+ self, use_sdata: bool, adata_hne_with_cluster: AnnData, sdata_visium_like
+ ) -> None:
+ """Both input types share a categorical-render assertion (W4.3)."""
+ if use_sdata:
+ fig = _spatial_scatter_via_sdata_plot(sdata_visium_like, color="ct", library_id="lib1")
+ else:
+ with pytest.warns(DeprecationWarning):
+ fig = _spatial_scatter_via_sdata_plot(adata_hne_with_cluster, color="cluster_path1")
+ assert isinstance(fig, Figure)
+ plt.close(fig)
+
+
+class TestPublicAPIFlag:
+ """The SQUIDPY_USE_SDATAPLOT flag routes the public sq.pl entrypoint through delegation."""
+
+ def test_public_spatial_scatter_routes_and_warns(self, adata_hne_with_cluster: AnnData, monkeypatch) -> None:
+ import squidpy as sq
+
+ monkeypatch.setenv("SQUIDPY_USE_SDATAPLOT", "1")
+ with pytest.warns(DeprecationWarning, match="deprecated"):
+ fig = sq.pl.spatial_scatter(adata_hne_with_cluster, color="cluster_path1")
+ assert isinstance(fig, Figure)
+ plt.close(fig)