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)