From 27ba8d29ba73d2b6b0796cfc190d9601b6dadae3 Mon Sep 17 00:00:00 2001 From: anon Date: Mon, 8 Jun 2026 14:36:12 +0200 Subject: [PATCH 01/28] Centroid extraction + squidpy obsm["spatial"] caching; shared scatter helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Infrastructure for an upcoming "render cells as centroid points" fast mode (no user-facing render option yet). Phase 0 — shared scatter primitive: - Extract `_scatter_points(ax, x, y, color_vector, ...)` from `_render_points`'s matplotlib branch; `_render_points` now calls it. Byte-identical output (verified vs main on categorical and continuous point renders). This is the reuse seam the fast mode will draw through. Phase 1 — centroid + caching core (headless, fully unit-tested): - `_compute_element_centroids` / `_compute_label_centroids`: per-instance centroids in a coordinate system. Shapes use spatialdata's vectorized `get_centroids`; labels use skimage `regionprops` (the per-label reduction is orders of magnitude faster than `get_centroids` on rasters), mapped onto the raster's intrinsic coordinate arrays so it reproduces `get_centroids` exactly (incl. the pixel-center 0.5 offset) then transformed to the target CS. - `_get_or_compute_centroids`: reuses/persists centroids via the squidpy convention. A pre-existing `obsm["spatial"]` (loader/user-provided) is trusted as the cells' locations; otherwise centroids are computed and written back into the annotating table's `obsm["spatial"]` with a coordinate-system provenance marker in `uns`, so later renders are instant. Reads run before writes, so a valid existing cache is reused rather than clobbered; an incompatible existing `obsm["spatial"]` is never overwritten; the cache is invalidated when the requested coordinate system differs. - Tests: shapes/labels centroids match `get_centroids`; cache round-trip + provenance; CS invalidation; pre-existing obsm trusted; no-table compute path; `cache=False` writes nothing. --- src/spatialdata_plot/pl/render.py | 47 +++++++-- src/spatialdata_plot/pl/utils.py | 167 +++++++++++++++++++++++++++++- tests/pl/test_utils.py | 65 ++++++++++++ 3 files changed, 271 insertions(+), 8 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 9ee64033..160b4087 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -21,7 +21,7 @@ from anndata import AnnData from matplotlib import patheffects from matplotlib.cm import ScalarMappable -from matplotlib.colors import ListedColormap, Normalize +from matplotlib.colors import Colormap, ListedColormap, Normalize from scanpy._settings import settings as sc_settings from scanpy.plotting._tools.scatterplots import _add_categorical_legend from spatialdata import get_extent, get_values @@ -1052,6 +1052,40 @@ def _render_shapes( ) +def _scatter_points( + ax: matplotlib.axes.SubplotBase, + x: Any, + y: Any, + color_vector: Any, + *, + size: float, + cmap: Colormap, + norm: Normalize | None, + alpha: float, + trans_data: Any, + zorder: int, +) -> Any: + """Draw one marker per (x, y) colored by ``color_vector`` via ``ax.scatter``. + + Shared matplotlib scatter primitive: used by ``_render_points`` and (later) by the + centroid-point "fast" rendering of shapes/labels. ``color_vector`` is per-point hex + strings (categorical) or numeric values mapped through ``cmap``/``norm`` (continuous). + """ + return ax.scatter( + x, + y, + s=size, + c=color_vector, + rasterized=sc_settings._vector_friendly, + cmap=cmap, + norm=norm, + alpha=alpha, + transform=trans_data, + zorder=zorder, + plotnonfinite=True, # nan points should be rendered as well + ) + + def _render_points( sdata: sd.SpatialData, render_params: PointsRenderParams, @@ -1404,18 +1438,17 @@ def _render_points( elif method == "matplotlib": # update axis limits if plot was empty before (necessary if datashader comes after) update_parameters = not _mpl_ax_contains_elements(ax) - cax = ax.scatter( + cax = _scatter_points( + ax, adata[:, 0].X.flatten(), adata[:, 1].X.flatten(), - s=render_params.size, - c=color_vector, - rasterized=sc_settings._vector_friendly, + color_vector, + size=render_params.size, cmap=render_params.cmap_params.cmap, norm=norm, alpha=render_params.alpha, - transform=trans_data, + trans_data=trans_data, zorder=render_params.zorder, - plotnonfinite=True, # nan points should be rendered as well ) if update_parameters: # necessary if points are plotted with mpl first and then with datashader diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 5ddefdb9..81b29a74 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -55,19 +55,29 @@ from scipy.spatial import ConvexHull from shapely.errors import GEOSException from skimage.color import label2rgb +from skimage.measure import regionprops_table from skimage.morphology import erosion, footprint_rectangle from skimage.util import map_array from spatialdata import ( SpatialData, + get_centroids, get_element_annotators, get_extent, get_values, join_spatialelement_table, rasterize, ) +from spatialdata._core.operations.transform import transform as sd_transform from spatialdata._core.query.relational_query import _locate_value from spatialdata._types import ArrayLike -from spatialdata.models import Image2DModel, Labels2DModel, SpatialElement, get_table_keys +from spatialdata.models import ( + Image2DModel, + Labels2DModel, + PointsModel, + SpatialElement, + get_model, + get_table_keys, +) from spatialdata.transformations.operations import get_transformation from spatialdata.transformations.transformations import Scale, Translation from spatialdata.transformations.transformations import Sequence as TransformSequence @@ -4417,3 +4427,158 @@ def _convert_alpha_to_datashader_range(alpha: float) -> float: """Convert alpha from the range [0, 1] to the range [0, 255] used in datashader.""" # prevent a value of 255, bc that led to fully colored test plots instead of just colored points/shapes return min([254, alpha * 255]) + + +# --- Cell-centroid extraction & caching (squidpy `obsm["spatial"]` convention) --- + +# squidpy/scanpy canonical key for per-cell coordinates: an N x 2 array, row-aligned to obs. +_CENTROID_OBSM_KEY = "spatial" +# Provenance marker so we know which element / coordinate system a cached `obsm["spatial"]` +# was computed for, and can invalidate it when the requested coordinate system changes. +_CENTROID_PROVENANCE_UNS_KEY = "spatialdata_plot" + + +def _compute_label_centroids(element: DataArray | DataTree, coordinate_system: str) -> pd.DataFrame: + """Per-label centroids via skimage ``regionprops`` + the element's coordinate transform. + + ``regionprops`` does the per-label reduction in a single C pass, which is orders of + magnitude faster than ``spatialdata.get_centroids`` on labels (that scans the raster + row/column-wise and scales with raster area). The intrinsic pixel centroids are then + mapped into ``coordinate_system`` with spatialdata's own transform machinery. + """ + raster = element + if isinstance(element, DataTree): + # full-resolution level, matching spatialdata.get_centroids + raster = next(iter(element["scale0"].values())) + values = np.asarray(raster.data) # materialize the intrinsic pixel grid + props = regionprops_table(values, properties=("label", "centroid")) + + # regionprops excludes background (label 0); centroid-0 = row (y), centroid-1 = col (x) + # as 0-based fractional indices. Map them onto the raster's intrinsic coordinate arrays + # (spatialdata uses pixel-center coords 0.5, 1.5, ... and possibly non-unit spacing), + # which reproduces spatialdata.get_centroids exactly. + def _index_to_coord(idx: ArrayLike, coord: ArrayLike) -> ArrayLike: + spacing = (coord[1] - coord[0]) if len(coord) > 1 else 1.0 + return coord[0] + idx * spacing + + xcoord = np.asarray(raster.coords["x"].values) + ycoord = np.asarray(raster.coords["y"].values) + intrinsic = pd.DataFrame( + { + "x": _index_to_coord(props["centroid-1"], xcoord), + "y": _index_to_coord(props["centroid-0"], ycoord), + }, + index=props["label"], + ) + t = get_transformation(raster, coordinate_system) + points = PointsModel.parse(intrinsic, transformations={coordinate_system: t}) + mapped = sd_transform(points, to_coordinate_system=coordinate_system).compute() + return mapped[["x", "y"]] + + +def _compute_element_centroids(sdata: SpatialData, element_name: str, coordinate_system: str) -> pd.DataFrame: + """Compute one centroid per instance of a shapes/labels element. + + Returns a DataFrame indexed by instance id (shape index / label value) with columns + ``["x", "y"]`` in ``coordinate_system``. Shapes use spatialdata's vectorized + ``get_centroids`` (already fast); labels use the regionprops path above. + """ + element = sdata[element_name] + if get_model(element) is Labels2DModel: + return _compute_label_centroids(element, coordinate_system) + centroids = get_centroids(element, coordinate_system=coordinate_system).compute() + return centroids[["x", "y"]] + + +def _read_cached_centroids( + sdata: SpatialData, element_name: str, table_name: str, coordinate_system: str +) -> pd.DataFrame | None: + """Return centroids from the annotating table's ``obsm["spatial"]`` if usable, else None. + + A pre-existing ``obsm["spatial"]`` (loader/user-provided) is trusted as the cells' + locations (squidpy convention). One that *we* wrote is reused only when our provenance + marker records the same coordinate system. + """ + table = sdata.tables[table_name] + if _CENTROID_OBSM_KEY not in table.obsm: + return None + coords = np.asarray(table.obsm[_CENTROID_OBSM_KEY], dtype=float) + if coords.ndim != 2 or coords.shape[0] != table.n_obs or coords.shape[1] < 2: + return None + _, region_key, instance_key = get_table_keys(table) + mask = (table.obs[region_key].astype(str) == str(element_name)).to_numpy() + if not mask.any(): + return None + region_coords = coords[mask][:, :2] + if np.isnan(region_coords).any(): + return None # not (fully) populated for this element + prov_root = table.uns.get(_CENTROID_PROVENANCE_UNS_KEY) + prov = prov_root.get("centroids", {}).get(element_name) if isinstance(prov_root, dict) else None + if prov is not None and prov.get("coordinate_system") != coordinate_system: + return None # we cached it for a different coordinate system -> recompute + idx = table.obs[instance_key].to_numpy()[mask] + return pd.DataFrame({"x": region_coords[:, 0], "y": region_coords[:, 1]}, index=idx) + + +def _write_cached_centroids( + sdata: SpatialData, element_name: str, table_name: str, coordinate_system: str, centroids: pd.DataFrame +) -> None: + """Persist centroids into the annotating table's ``obsm["spatial"]`` + a provenance marker. + + Fills only the rows belonging to ``element_name`` (a table may annotate several + elements); other rows are left NaN. Never clobbers an incompatible existing + ``obsm["spatial"]``. Callers run the read path first, so a valid existing cache is + reused rather than overwritten. + """ + table = sdata.tables[table_name] + _, region_key, instance_key = get_table_keys(table) + mask = (table.obs[region_key].astype(str) == str(element_name)).to_numpy() + if not mask.any(): + return + if _CENTROID_OBSM_KEY in table.obsm: + existing = np.asarray(table.obsm[_CENTROID_OBSM_KEY], dtype=float) + if existing.ndim != 2 or existing.shape != (table.n_obs, 2): + return # don't clobber an incompatible obsm["spatial"] + arr = existing.copy() + else: + arr = np.full((table.n_obs, 2), np.nan, dtype=float) + aligned = centroids.reindex(table.obs[instance_key].to_numpy()[mask]) + arr[mask, 0] = aligned["x"].to_numpy() + arr[mask, 1] = aligned["y"].to_numpy() + table.obsm[_CENTROID_OBSM_KEY] = arr + prov_root = table.uns.setdefault(_CENTROID_PROVENANCE_UNS_KEY, {}) + if not isinstance(prov_root, dict): + return + prov_root.setdefault("centroids", {})[element_name] = { + "coordinate_system": coordinate_system, + "n": int(mask.sum()), + "key": _CENTROID_OBSM_KEY, + } + + +def _get_or_compute_centroids( + sdata: SpatialData, + element_name: str, + *, + coordinate_system: str, + table_name: str | None = None, + cache: bool = True, +) -> pd.DataFrame: + """Per-instance centroids, reusing/persisting them via the squidpy ``obsm["spatial"]`` convention. + + If the annotating table already carries cell coordinates in ``obsm["spatial"]`` (loader-, + user-, or previously-cached for the same coordinate system), they are reused with no + recomputation. Otherwise centroids are computed and, when ``cache`` is True and an + annotating table exists, written back so later renders are instant. + + Returns a DataFrame indexed by instance id with columns ``["x", "y"]``. + """ + table = table_name if (table_name is not None and table_name in sdata.tables) else None + if table is not None: + cached = _read_cached_centroids(sdata, element_name, table, coordinate_system) + if cached is not None: + return cached + centroids = _compute_element_centroids(sdata, element_name, coordinate_system) + if cache and table is not None: + _write_cached_centroids(sdata, element_name, table, coordinate_system, centroids) + return centroids diff --git a/tests/pl/test_utils.py b/tests/pl/test_utils.py index 4a7c8a5b..1d7b9c67 100644 --- a/tests/pl/test_utils.py +++ b/tests/pl/test_utils.py @@ -5,6 +5,7 @@ import pandas as pd import pytest import scanpy as sc +import spatialdata as sd import xarray as xr from anndata import AnnData from shapely.geometry import Point @@ -14,8 +15,13 @@ import spatialdata_plot from spatialdata_plot.pl.render_params import Color, ColorLike from spatialdata_plot.pl.utils import ( + _CENTROID_OBSM_KEY, + _CENTROID_PROVENANCE_UNS_KEY, _apply_cmap_alpha_to_datashader_result, + _compute_element_centroids, _datashader_map_aggregate_to_color, + _get_or_compute_centroids, + _read_cached_centroids, _set_outline, set_zero_in_cmap_to_transparent, ) @@ -496,3 +502,62 @@ def fake_rasterize(*args, **kwargs): assert captured["target_unit_to_pixels"] == pytest.approx(0.15, rel=1e-6), ( f"Expected world-unit basis (0.15), got {captured['target_unit_to_pixels']}" ) + + +class TestCentroids: + """Centroid extraction + squidpy ``obsm['spatial']`` caching (no rendering).""" + + def test_shapes_centroids_match_get_centroids(self, sdata_blobs: SpatialData): + mine = _compute_element_centroids(sdata_blobs, "blobs_circles", "global").sort_index() + ref = sd.get_centroids(sdata_blobs["blobs_circles"]).compute()[["x", "y"]].sort_index() + assert np.allclose(mine.values, ref.values) + + def test_labels_centroids_match_get_centroids(self, sdata_blobs: SpatialData): + # regionprops path must reproduce spatialdata's exact center-of-mass (incl. the + # pixel-center 0.5 offset), just much faster. + mine = _compute_element_centroids(sdata_blobs, "blobs_labels", "global").sort_index() + ref = sd.get_centroids(sdata_blobs["blobs_labels"]).compute()[["x", "y"]].sort_index() + assert list(mine.index) == list(ref.index) + assert np.allclose(mine.values, ref.values, atol=1e-9) + + def test_cache_round_trip_and_provenance(self, sdata_blobs: SpatialData): + table = sdata_blobs["table"] + assert _CENTROID_OBSM_KEY not in table.obsm + computed = _get_or_compute_centroids( + sdata_blobs, "blobs_labels", coordinate_system="global", table_name="table" + ) + assert _CENTROID_OBSM_KEY in table.obsm + prov = table.uns[_CENTROID_PROVENANCE_UNS_KEY]["centroids"]["blobs_labels"] + assert prov["coordinate_system"] == "global" + assert prov["n"] == len(computed) + cached = _read_cached_centroids(sdata_blobs, "blobs_labels", "table", "global") + assert cached is not None + assert np.allclose(cached.sort_index().values, computed.sort_index().values) + + def test_cache_invalidated_on_coordinate_system_change(self, sdata_blobs: SpatialData): + _get_or_compute_centroids(sdata_blobs, "blobs_labels", coordinate_system="global", table_name="table") + assert _read_cached_centroids(sdata_blobs, "blobs_labels", "table", "global") is not None + # cached for "global" must not be reused for a different coordinate system + assert _read_cached_centroids(sdata_blobs, "blobs_labels", "table", "other_cs") is None + + def test_preexisting_obsm_spatial_is_trusted(self, sdata_blobs: SpatialData): + # a loader/user-provided obsm["spatial"] is used as the cells' locations (squidpy + # convention) without recomputation. + table = sdata_blobs["table"] + coords = np.arange(table.n_obs * 2, dtype=float).reshape(table.n_obs, 2) + table.obsm[_CENTROID_OBSM_KEY] = coords + got = _read_cached_centroids(sdata_blobs, "blobs_labels", "table", "global") + assert got is not None + assert len(got) == table.n_obs # the whole table annotates this single region + + def test_no_annotating_table_computes_without_caching(self, sdata_blobs: SpatialData): + # blobs_circles has no annotating table -> compute and return, no persistence. + centroids = _get_or_compute_centroids(sdata_blobs, "blobs_circles", coordinate_system="global", table_name=None) + assert list(centroids.columns) == ["x", "y"] + assert len(centroids) > 0 + + def test_cache_false_does_not_write(self, sdata_blobs: SpatialData): + _get_or_compute_centroids( + sdata_blobs, "blobs_labels", coordinate_system="global", table_name="table", cache=False + ) + assert _CENTROID_OBSM_KEY not in sdata_blobs["table"].obsm From 3bad4c096f7a4fa7854e0587235d88e5bd311fcf Mon Sep 17 00:00:00 2001 From: anon Date: Mon, 8 Jun 2026 14:46:01 +0200 Subject: [PATCH 02/28] Simplify centroid cache: dedup region mask/keys, fix half-write, trim provenance Cleanup from /simplify (no behavioral change): - Extract `_region_mask_and_keys(table, element)` used by both read and write, removing the duplicated `get_table_keys` + O(n_obs) `region_key`-string-cast mask that was computed twice per cold call. - Read path: validate shape on the raw obsm array and cast only the masked subset to float, instead of casting the whole `obsm["spatial"]` on every cache hit (the hot path). - Write path: coerce a non-dict `uns["spatialdata_plot"]` instead of early returning after `obsm` was already mutated, so obsm and the provenance marker are always written together (no half-write). - Drop the dead `"key"` provenance field (constant, never read back). - Rename the misleading `table` local (held a table *name*) in `_get_or_compute_centroids`. --- src/spatialdata_plot/pl/utils.py | 39 +++++++++++++++++++------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 81b29a74..0d266a02 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -4490,6 +4490,13 @@ def _compute_element_centroids(sdata: SpatialData, element_name: str, coordinate return centroids[["x", "y"]] +def _region_mask_and_keys(table: AnnData, element_name: str) -> tuple[str, ArrayLike]: + """Return ``(instance_key, mask)`` for the rows of ``table`` that annotate ``element_name``.""" + _, region_key, instance_key = get_table_keys(table) + mask = (table.obs[region_key].astype(str) == str(element_name)).to_numpy() + return instance_key, mask + + def _read_cached_centroids( sdata: SpatialData, element_name: str, table_name: str, coordinate_system: str ) -> pd.DataFrame | None: @@ -4502,14 +4509,13 @@ def _read_cached_centroids( table = sdata.tables[table_name] if _CENTROID_OBSM_KEY not in table.obsm: return None - coords = np.asarray(table.obsm[_CENTROID_OBSM_KEY], dtype=float) + coords = np.asarray(table.obsm[_CENTROID_OBSM_KEY]) if coords.ndim != 2 or coords.shape[0] != table.n_obs or coords.shape[1] < 2: return None - _, region_key, instance_key = get_table_keys(table) - mask = (table.obs[region_key].astype(str) == str(element_name)).to_numpy() + instance_key, mask = _region_mask_and_keys(table, element_name) if not mask.any(): return None - region_coords = coords[mask][:, :2] + region_coords = coords[mask][:, :2].astype(float) if np.isnan(region_coords).any(): return None # not (fully) populated for this element prov_root = table.uns.get(_CENTROID_PROVENANCE_UNS_KEY) @@ -4531,28 +4537,29 @@ def _write_cached_centroids( reused rather than overwritten. """ table = sdata.tables[table_name] - _, region_key, instance_key = get_table_keys(table) - mask = (table.obs[region_key].astype(str) == str(element_name)).to_numpy() + instance_key, mask = _region_mask_and_keys(table, element_name) if not mask.any(): return if _CENTROID_OBSM_KEY in table.obsm: - existing = np.asarray(table.obsm[_CENTROID_OBSM_KEY], dtype=float) + existing = np.asarray(table.obsm[_CENTROID_OBSM_KEY]) if existing.ndim != 2 or existing.shape != (table.n_obs, 2): return # don't clobber an incompatible obsm["spatial"] - arr = existing.copy() + arr = existing.astype(float, copy=True) else: arr = np.full((table.n_obs, 2), np.nan, dtype=float) aligned = centroids.reindex(table.obs[instance_key].to_numpy()[mask]) arr[mask, 0] = aligned["x"].to_numpy() arr[mask, 1] = aligned["y"].to_numpy() table.obsm[_CENTROID_OBSM_KEY] = arr - prov_root = table.uns.setdefault(_CENTROID_PROVENANCE_UNS_KEY, {}) + # We own the `spatialdata_plot` uns namespace; coerce anything unexpected so obsm and + # the provenance marker are always written together (no half-write). + prov_root = table.uns.get(_CENTROID_PROVENANCE_UNS_KEY) if not isinstance(prov_root, dict): - return + prov_root = {} + table.uns[_CENTROID_PROVENANCE_UNS_KEY] = prov_root prov_root.setdefault("centroids", {})[element_name] = { "coordinate_system": coordinate_system, "n": int(mask.sum()), - "key": _CENTROID_OBSM_KEY, } @@ -4573,12 +4580,12 @@ def _get_or_compute_centroids( Returns a DataFrame indexed by instance id with columns ``["x", "y"]``. """ - table = table_name if (table_name is not None and table_name in sdata.tables) else None - if table is not None: - cached = _read_cached_centroids(sdata, element_name, table, coordinate_system) + resolved_table = table_name if (table_name is not None and table_name in sdata.tables) else None + if resolved_table is not None: + cached = _read_cached_centroids(sdata, element_name, resolved_table, coordinate_system) if cached is not None: return cached centroids = _compute_element_centroids(sdata, element_name, coordinate_system) - if cache and table is not None: - _write_cached_centroids(sdata, element_name, table, coordinate_system, centroids) + if cache and resolved_table is not None: + _write_cached_centroids(sdata, element_name, resolved_table, coordinate_system, centroids) return centroids From 23da9bc8fd5023cbebf604489bc8529ec7db5a82 Mon Sep 17 00:00:00 2001 From: anon Date: Mon, 8 Jun 2026 15:35:23 +0200 Subject: [PATCH 03/28] Store centroids intrinsic (coordinate-system-independent cache) Refactor the centroid cache to store element-*intrinsic* coordinates and transform to the render coordinate system on demand, instead of caching coords already mapped into one coordinate system. Decisions from design pass: - Intrinsic storage: one `obsm["spatial"]` cache serves every coordinate system (proven equivalent to per-CS computation). `_compute_element_centroids` returns intrinsic coords (shapes via shapely `.centroid`, labels via `regionprops`); `_centroids_to_coordinate_system` maps them to the requested CS via the element's transform; `_get_or_compute_centroids` reads/computes intrinsic then transforms on return. - Provenance records `{n, scale_level}` (no coordinate system). Cache is invalidated when the region's instance count changes (cells added/removed), not on CS change. - A pre-existing `obsm["spatial"]` (loader/user-provided) is trusted as the cells' intrinsic locations and transformed to the render CS. - Exhaustive model dispatch: shapes and 2D labels supported; other element types raise NotImplementedError. - Labels are reduced at full resolution (scale0). Drops the now-unused `get_centroids` import; adds `ShapesModel`. Tests updated: parametrized shapes/labels match `get_centroids`; new coordinate-system- independence test (one cache, two CS); staleness-by-instance-count; trusted pre-existing obsm; unsupported-type rejection. --- src/spatialdata_plot/pl/utils.py | 145 +++++++++++++++++-------------- tests/pl/test_utils.py | 76 ++++++++++------ 2 files changed, 131 insertions(+), 90 deletions(-) diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 0d266a02..6348c689 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -60,7 +60,6 @@ from skimage.util import map_array from spatialdata import ( SpatialData, - get_centroids, get_element_annotators, get_extent, get_values, @@ -74,6 +73,7 @@ Image2DModel, Labels2DModel, PointsModel, + ShapesModel, SpatialElement, get_model, get_table_keys, @@ -4433,61 +4433,78 @@ def _convert_alpha_to_datashader_range(alpha: float) -> float: # squidpy/scanpy canonical key for per-cell coordinates: an N x 2 array, row-aligned to obs. _CENTROID_OBSM_KEY = "spatial" -# Provenance marker so we know which element / coordinate system a cached `obsm["spatial"]` -# was computed for, and can invalidate it when the requested coordinate system changes. +# Our provenance namespace in `table.uns`. Centroids are stored in the element's *intrinsic* +# coordinates (coordinate-system-independent), so the marker records the resolution level and +# the instance count (to invalidate when cells are added/removed), not a coordinate system. _CENTROID_PROVENANCE_UNS_KEY = "spatialdata_plot" +_CENTROID_SCALE_LEVEL = "scale0" # labels are reduced at full resolution (decision: exact) -def _compute_label_centroids(element: DataArray | DataTree, coordinate_system: str) -> pd.DataFrame: - """Per-label centroids via skimage ``regionprops`` + the element's coordinate transform. +def _transformable_raster(element: DataArray | DataTree) -> DataArray: + """Return the raster level (``scale0`` for multiscale) carrying intrinsic coords + transforms.""" + if isinstance(element, DataTree): + return next(iter(element[_CENTROID_SCALE_LEVEL].values())) + return element + - ``regionprops`` does the per-label reduction in a single C pass, which is orders of - magnitude faster than ``spatialdata.get_centroids`` on labels (that scans the raster - row/column-wise and scales with raster area). The intrinsic pixel centroids are then - mapped into ``coordinate_system`` with spatialdata's own transform machinery. +def _compute_label_centroids(element: DataArray | DataTree) -> pd.DataFrame: + """Per-label centroids in the element's *intrinsic* coordinates via skimage ``regionprops``. + + ``regionprops`` does the per-label reduction in a single C pass, orders of magnitude + faster than ``spatialdata.get_centroids`` on labels (which scans the raster row/column-wise + and scales with raster area). Returns intrinsic coords; the caller maps them to a target + coordinate system on demand. """ - raster = element - if isinstance(element, DataTree): - # full-resolution level, matching spatialdata.get_centroids - raster = next(iter(element["scale0"].values())) + raster = _transformable_raster(element) values = np.asarray(raster.data) # materialize the intrinsic pixel grid props = regionprops_table(values, properties=("label", "centroid")) # regionprops excludes background (label 0); centroid-0 = row (y), centroid-1 = col (x) # as 0-based fractional indices. Map them onto the raster's intrinsic coordinate arrays - # (spatialdata uses pixel-center coords 0.5, 1.5, ... and possibly non-unit spacing), - # which reproduces spatialdata.get_centroids exactly. + # (spatialdata uses pixel-center coords 0.5, 1.5, ... and possibly non-unit spacing). def _index_to_coord(idx: ArrayLike, coord: ArrayLike) -> ArrayLike: spacing = (coord[1] - coord[0]) if len(coord) > 1 else 1.0 return coord[0] + idx * spacing xcoord = np.asarray(raster.coords["x"].values) ycoord = np.asarray(raster.coords["y"].values) - intrinsic = pd.DataFrame( + return pd.DataFrame( { "x": _index_to_coord(props["centroid-1"], xcoord), "y": _index_to_coord(props["centroid-0"], ycoord), }, index=props["label"], ) - t = get_transformation(raster, coordinate_system) - points = PointsModel.parse(intrinsic, transformations={coordinate_system: t}) - mapped = sd_transform(points, to_coordinate_system=coordinate_system).compute() - return mapped[["x", "y"]] -def _compute_element_centroids(sdata: SpatialData, element_name: str, coordinate_system: str) -> pd.DataFrame: - """Compute one centroid per instance of a shapes/labels element. +def _compute_element_centroids(sdata: SpatialData, element_name: str) -> pd.DataFrame: + """One centroid per instance of a shapes/labels element, in the element's intrinsic coords. Returns a DataFrame indexed by instance id (shape index / label value) with columns - ``["x", "y"]`` in ``coordinate_system``. Shapes use spatialdata's vectorized - ``get_centroids`` (already fast); labels use the regionprops path above. + ``["x", "y"]``. Shapes use shapely's vectorized ``.centroid``; labels use ``regionprops``. + Other element types are rejected explicitly. """ element = sdata[element_name] - if get_model(element) is Labels2DModel: - return _compute_label_centroids(element, coordinate_system) - centroids = get_centroids(element, coordinate_system=coordinate_system).compute() - return centroids[["x", "y"]] + model = get_model(element) + if model is ShapesModel: + centroids = element.geometry.centroid + return pd.DataFrame({"x": centroids.x.to_numpy(), "y": centroids.y.to_numpy()}, index=element.index) + if model is Labels2DModel: + return _compute_label_centroids(element) + raise NotImplementedError( + f"Centroid extraction is only supported for shapes and 2D labels; " + f"element {element_name!r} is a {model.__name__}." + ) + + +def _centroids_to_coordinate_system( + intrinsic: pd.DataFrame, element: SpatialElement, coordinate_system: str +) -> pd.DataFrame: + """Map intrinsic centroids to ``coordinate_system`` via the element's transform (preserves index).""" + target = _transformable_raster(element) if isinstance(element, DataArray | DataTree) else element + t = get_transformation(target, coordinate_system) + points = PointsModel.parse(intrinsic, transformations={coordinate_system: t}) + return sd_transform(points, to_coordinate_system=coordinate_system).compute()[["x", "y"]] def _region_mask_and_keys(table: AnnData, element_name: str) -> tuple[str, ArrayLike]: @@ -4497,14 +4514,21 @@ def _region_mask_and_keys(table: AnnData, element_name: str) -> tuple[str, Array return instance_key, mask -def _read_cached_centroids( - sdata: SpatialData, element_name: str, table_name: str, coordinate_system: str -) -> pd.DataFrame | None: - """Return centroids from the annotating table's ``obsm["spatial"]`` if usable, else None. +def _centroid_provenance(table: AnnData, element_name: str) -> dict[str, Any] | None: + """Our centroid provenance marker for ``element_name``, or None if absent/malformed.""" + root = table.uns.get(_CENTROID_PROVENANCE_UNS_KEY) + if not isinstance(root, dict): + return None + marker = root.get("centroids", {}).get(element_name) + return marker if isinstance(marker, dict) else None + - A pre-existing ``obsm["spatial"]`` (loader/user-provided) is trusted as the cells' - locations (squidpy convention). One that *we* wrote is reused only when our provenance - marker records the same coordinate system. +def _read_cached_centroids(sdata: SpatialData, element_name: str, table_name: str) -> pd.DataFrame | None: + """Return *intrinsic* centroids from the annotating table's ``obsm["spatial"]`` if usable. + + A pre-existing ``obsm["spatial"]`` (loader/user-provided) is trusted as the cells' intrinsic + locations (squidpy convention). One that *we* wrote is reused only while its recorded + instance count still matches the table (so adding/removing cells invalidates it). """ table = sdata.tables[table_name] if _CENTROID_OBSM_KEY not in table.obsm: @@ -4518,23 +4542,19 @@ def _read_cached_centroids( region_coords = coords[mask][:, :2].astype(float) if np.isnan(region_coords).any(): return None # not (fully) populated for this element - prov_root = table.uns.get(_CENTROID_PROVENANCE_UNS_KEY) - prov = prov_root.get("centroids", {}).get(element_name) if isinstance(prov_root, dict) else None - if prov is not None and prov.get("coordinate_system") != coordinate_system: - return None # we cached it for a different coordinate system -> recompute + prov = _centroid_provenance(table, element_name) + if prov is not None and prov.get("n") != int(mask.sum()): + return None # cells added/removed since we cached -> recompute idx = table.obs[instance_key].to_numpy()[mask] return pd.DataFrame({"x": region_coords[:, 0], "y": region_coords[:, 1]}, index=idx) -def _write_cached_centroids( - sdata: SpatialData, element_name: str, table_name: str, coordinate_system: str, centroids: pd.DataFrame -) -> None: - """Persist centroids into the annotating table's ``obsm["spatial"]`` + a provenance marker. +def _write_cached_centroids(sdata: SpatialData, element_name: str, table_name: str, centroids: pd.DataFrame) -> None: + """Persist *intrinsic* centroids into the annotating table's ``obsm["spatial"]`` + provenance. - Fills only the rows belonging to ``element_name`` (a table may annotate several - elements); other rows are left NaN. Never clobbers an incompatible existing - ``obsm["spatial"]``. Callers run the read path first, so a valid existing cache is - reused rather than overwritten. + Fills only the rows belonging to ``element_name`` (a table may annotate several elements); + other rows are left NaN. Never clobbers an incompatible existing ``obsm["spatial"]``. + Callers run the read path first, so a valid existing cache is reused rather than overwritten. """ table = sdata.tables[table_name] instance_key, mask = _region_mask_and_keys(table, element_name) @@ -4551,15 +4571,15 @@ def _write_cached_centroids( arr[mask, 0] = aligned["x"].to_numpy() arr[mask, 1] = aligned["y"].to_numpy() table.obsm[_CENTROID_OBSM_KEY] = arr - # We own the `spatialdata_plot` uns namespace; coerce anything unexpected so obsm and - # the provenance marker are always written together (no half-write). + # We own the `spatialdata_plot` uns namespace; coerce anything unexpected so obsm and the + # provenance marker are always written together (no half-write). prov_root = table.uns.get(_CENTROID_PROVENANCE_UNS_KEY) if not isinstance(prov_root, dict): prov_root = {} table.uns[_CENTROID_PROVENANCE_UNS_KEY] = prov_root prov_root.setdefault("centroids", {})[element_name] = { - "coordinate_system": coordinate_system, "n": int(mask.sum()), + "scale_level": _CENTROID_SCALE_LEVEL, } @@ -4571,21 +4591,20 @@ def _get_or_compute_centroids( table_name: str | None = None, cache: bool = True, ) -> pd.DataFrame: - """Per-instance centroids, reusing/persisting them via the squidpy ``obsm["spatial"]`` convention. + """Per-instance centroids in ``coordinate_system``, cached via the squidpy ``obsm["spatial"]`` convention. - If the annotating table already carries cell coordinates in ``obsm["spatial"]`` (loader-, - user-, or previously-cached for the same coordinate system), they are reused with no - recomputation. Otherwise centroids are computed and, when ``cache`` is True and an - annotating table exists, written back so later renders are instant. + Centroids are stored intrinsic (coordinate-system-independent) and transformed to + ``coordinate_system`` on return, so one cache serves every coordinate system. If the + annotating table already carries cell coordinates in ``obsm["spatial"]`` (loader-, user-, + or previously cached), they are reused with no recomputation; otherwise centroids are + computed and, when ``cache`` is True and an annotating table exists, written back. Returns a DataFrame indexed by instance id with columns ``["x", "y"]``. """ resolved_table = table_name if (table_name is not None and table_name in sdata.tables) else None - if resolved_table is not None: - cached = _read_cached_centroids(sdata, element_name, resolved_table, coordinate_system) - if cached is not None: - return cached - centroids = _compute_element_centroids(sdata, element_name, coordinate_system) - if cache and resolved_table is not None: - _write_cached_centroids(sdata, element_name, resolved_table, coordinate_system, centroids) - return centroids + intrinsic = _read_cached_centroids(sdata, element_name, resolved_table) if resolved_table is not None else None + if intrinsic is None: + intrinsic = _compute_element_centroids(sdata, element_name) + if cache and resolved_table is not None: + _write_cached_centroids(sdata, element_name, resolved_table, intrinsic) + return _centroids_to_coordinate_system(intrinsic, sdata[element_name], coordinate_system) diff --git a/tests/pl/test_utils.py b/tests/pl/test_utils.py index 1d7b9c67..d5e51a64 100644 --- a/tests/pl/test_utils.py +++ b/tests/pl/test_utils.py @@ -507,48 +507,70 @@ def fake_rasterize(*args, **kwargs): class TestCentroids: """Centroid extraction + squidpy ``obsm['spatial']`` caching (no rendering).""" - def test_shapes_centroids_match_get_centroids(self, sdata_blobs: SpatialData): - mine = _compute_element_centroids(sdata_blobs, "blobs_circles", "global").sort_index() - ref = sd.get_centroids(sdata_blobs["blobs_circles"]).compute()[["x", "y"]].sort_index() - assert np.allclose(mine.values, ref.values) - - def test_labels_centroids_match_get_centroids(self, sdata_blobs: SpatialData): - # regionprops path must reproduce spatialdata's exact center-of-mass (incl. the - # pixel-center 0.5 offset), just much faster. - mine = _compute_element_centroids(sdata_blobs, "blobs_labels", "global").sort_index() - ref = sd.get_centroids(sdata_blobs["blobs_labels"]).compute()[["x", "y"]].sort_index() + @pytest.mark.parametrize(("element", "table_name"), [("blobs_circles", None), ("blobs_labels", "table")]) + def test_centroids_match_get_centroids(self, sdata_blobs: SpatialData, element: str, table_name: str | None): + # shapes via shapely .centroid, labels via regionprops (incl. the pixel-center 0.5 + # offset) -> identical to spatialdata's exact centroids, just faster. + mine = _get_or_compute_centroids( + sdata_blobs, element, coordinate_system="global", table_name=table_name + ).sort_index() + ref = sd.get_centroids(sdata_blobs[element], coordinate_system="global").compute()[["x", "y"]].sort_index() assert list(mine.index) == list(ref.index) assert np.allclose(mine.values, ref.values, atol=1e-9) + def test_cache_is_coordinate_system_independent(self, sdata_blobs: SpatialData): + # one intrinsic cache serves every coordinate system: add a second CS and confirm both + # match get_centroids while only one obsm["spatial"] is written. + from spatialdata.transformations import Scale, set_transformation + + set_transformation( + sdata_blobs["blobs_labels"], Scale([2.0, 3.0], axes=("x", "y")), to_coordinate_system="scaled" + ) + g = _get_or_compute_centroids( + sdata_blobs, "blobs_labels", coordinate_system="global", table_name="table" + ).sort_index() + s = _get_or_compute_centroids( + sdata_blobs, "blobs_labels", coordinate_system="scaled", table_name="table" + ).sort_index() + assert _CENTROID_OBSM_KEY in sdata_blobs["table"].obsm # cached once, reused for both + ref_g = sd.get_centroids(sdata_blobs["blobs_labels"], coordinate_system="global").compute()[["x", "y"]] + ref_s = sd.get_centroids(sdata_blobs["blobs_labels"], coordinate_system="scaled").compute()[["x", "y"]] + assert np.allclose(g.values, ref_g.sort_index().values, atol=1e-9) + assert np.allclose(s.values, ref_s.sort_index().values, atol=1e-9) + assert not np.allclose(ref_g.values, ref_s.values) # the coordinate system genuinely matters + def test_cache_round_trip_and_provenance(self, sdata_blobs: SpatialData): table = sdata_blobs["table"] assert _CENTROID_OBSM_KEY not in table.obsm - computed = _get_or_compute_centroids( - sdata_blobs, "blobs_labels", coordinate_system="global", table_name="table" - ) - assert _CENTROID_OBSM_KEY in table.obsm + out = _get_or_compute_centroids(sdata_blobs, "blobs_labels", coordinate_system="global", table_name="table") + assert _CENTROID_OBSM_KEY in table.obsm # intrinsic centroids cached prov = table.uns[_CENTROID_PROVENANCE_UNS_KEY]["centroids"]["blobs_labels"] - assert prov["coordinate_system"] == "global" - assert prov["n"] == len(computed) - cached = _read_cached_centroids(sdata_blobs, "blobs_labels", "table", "global") - assert cached is not None - assert np.allclose(cached.sort_index().values, computed.sort_index().values) + assert prov["n"] == len(out) + assert prov["scale_level"] == "scale0" + assert "coordinate_system" not in prov # the cache is coordinate-system-independent + again = _get_or_compute_centroids(sdata_blobs, "blobs_labels", coordinate_system="global", table_name="table") + assert np.allclose(again.sort_index().values, out.sort_index().values) - def test_cache_invalidated_on_coordinate_system_change(self, sdata_blobs: SpatialData): + def test_cache_invalidated_on_instance_count_change(self, sdata_blobs: SpatialData): _get_or_compute_centroids(sdata_blobs, "blobs_labels", coordinate_system="global", table_name="table") - assert _read_cached_centroids(sdata_blobs, "blobs_labels", "table", "global") is not None - # cached for "global" must not be reused for a different coordinate system - assert _read_cached_centroids(sdata_blobs, "blobs_labels", "table", "other_cs") is None + assert _read_cached_centroids(sdata_blobs, "blobs_labels", "table") is not None + # simulate cells added/removed: the recorded instance count no longer matches the table. + sdata_blobs["table"].uns[_CENTROID_PROVENANCE_UNS_KEY]["centroids"]["blobs_labels"]["n"] += 1 + assert _read_cached_centroids(sdata_blobs, "blobs_labels", "table") is None def test_preexisting_obsm_spatial_is_trusted(self, sdata_blobs: SpatialData): - # a loader/user-provided obsm["spatial"] is used as the cells' locations (squidpy - # convention) without recomputation. + # a loader/user-provided obsm["spatial"] (no provenance) is trusted as the cells' + # intrinsic locations without recomputation. table = sdata_blobs["table"] coords = np.arange(table.n_obs * 2, dtype=float).reshape(table.n_obs, 2) table.obsm[_CENTROID_OBSM_KEY] = coords - got = _read_cached_centroids(sdata_blobs, "blobs_labels", "table", "global") + got = _read_cached_centroids(sdata_blobs, "blobs_labels", "table") assert got is not None - assert len(got) == table.n_obs # the whole table annotates this single region + assert np.allclose(got.values, coords) # whole table = this one region, order preserved + + def test_unsupported_element_type_raises(self, sdata_blobs: SpatialData): + with pytest.raises(NotImplementedError, match="shapes and 2D labels"): + _compute_element_centroids(sdata_blobs, "blobs_points") def test_no_annotating_table_computes_without_caching(self, sdata_blobs: SpatialData): # blobs_circles has no annotating table -> compute and return, no persistence. From 40d8cef54336e483a7bc6d23dff2aa9afdc16061 Mon Sep 17 00:00:00 2001 From: anon Date: Mon, 8 Jun 2026 15:47:56 +0200 Subject: [PATCH 04/28] Simplify centroids: numpy-affine transform on cache hits, drop private import, shared obsm gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup from /simplify: - `_centroids_to_coordinate_system` ran `PointsModel.parse` + a dask `transform(...).compute()` round trip on every call, including cache hits (~19 ms fixed floor, ~100 ms at 1M cells) — defeating the cache. Replace with `to_affine_matrix` + a plain numpy matmul: numerically identical (verified against the dask path for multiple coordinate systems), ~80-140x faster, and it removes the private-API import `spatialdata._core.operations.transform` (a repo non-negotiable) plus the now-unused `PointsModel`. - Widen `_transformable_raster` -> `_transform_carrier` to accept any element (rasters -> scale0, others as-is), dropping the `isinstance` branch in `_centroids_to_coordinate_system`. - Extract `_valid_spatial_obsm(arr, n_obs)` shared by the read and write paths, reconciling their previously divergent obsm-shape checks (read accepted >=2 columns, write required exactly 2) so they cannot drift. --- src/spatialdata_plot/pl/utils.py | 37 +++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 6348c689..78cb7301 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -66,13 +66,11 @@ join_spatialelement_table, rasterize, ) -from spatialdata._core.operations.transform import transform as sd_transform from spatialdata._core.query.relational_query import _locate_value from spatialdata._types import ArrayLike from spatialdata.models import ( Image2DModel, Labels2DModel, - PointsModel, ShapesModel, SpatialElement, get_model, @@ -4440,8 +4438,8 @@ def _convert_alpha_to_datashader_range(alpha: float) -> float: _CENTROID_SCALE_LEVEL = "scale0" # labels are reduced at full resolution (decision: exact) -def _transformable_raster(element: DataArray | DataTree) -> DataArray: - """Return the raster level (``scale0`` for multiscale) carrying intrinsic coords + transforms.""" +def _transform_carrier(element: SpatialElement) -> Any: + """Return the object carrying ``element``'s transforms (the ``scale0`` level for multiscale rasters).""" if isinstance(element, DataTree): return next(iter(element[_CENTROID_SCALE_LEVEL].values())) return element @@ -4455,7 +4453,7 @@ def _compute_label_centroids(element: DataArray | DataTree) -> pd.DataFrame: and scales with raster area). Returns intrinsic coords; the caller maps them to a target coordinate system on demand. """ - raster = _transformable_raster(element) + raster = _transform_carrier(element) values = np.asarray(raster.data) # materialize the intrinsic pixel grid props = regionprops_table(values, properties=("label", "centroid")) @@ -4500,11 +4498,16 @@ def _compute_element_centroids(sdata: SpatialData, element_name: str) -> pd.Data def _centroids_to_coordinate_system( intrinsic: pd.DataFrame, element: SpatialElement, coordinate_system: str ) -> pd.DataFrame: - """Map intrinsic centroids to ``coordinate_system`` via the element's transform (preserves index).""" - target = _transformable_raster(element) if isinstance(element, DataArray | DataTree) else element - t = get_transformation(target, coordinate_system) - points = PointsModel.parse(intrinsic, transformations={coordinate_system: t}) - return sd_transform(points, to_coordinate_system=coordinate_system).compute()[["x", "y"]] + """Map intrinsic centroids to ``coordinate_system`` via the element's affine transform. + + Applies the transform as a plain numpy matmul rather than a ``PointsModel`` + dask round + trip — this runs on every render (including cache hits), so the dask overhead would defeat + the cache. Preserves the instance-id index. + """ + t = get_transformation(_transform_carrier(element), coordinate_system) + matrix = t.to_affine_matrix(input_axes=("x", "y"), output_axes=("x", "y")) + xy = intrinsic[["x", "y"]].to_numpy() @ matrix[:2, :2].T + matrix[:2, 2] + return pd.DataFrame({"x": xy[:, 0], "y": xy[:, 1]}, index=intrinsic.index) def _region_mask_and_keys(table: AnnData, element_name: str) -> tuple[str, ArrayLike]: @@ -4514,6 +4517,14 @@ def _region_mask_and_keys(table: AnnData, element_name: str) -> tuple[str, Array return instance_key, mask +def _valid_spatial_obsm(arr: ArrayLike, n_obs: int) -> bool: + """Whether ``arr`` is a usable ``obsm["spatial"]``: a 2D ``(n_obs, 2)`` coordinate grid. + + Read and write share this gate so they cannot drift on the obsm layout. + """ + return bool(arr.ndim == 2 and arr.shape == (n_obs, 2)) + + def _centroid_provenance(table: AnnData, element_name: str) -> dict[str, Any] | None: """Our centroid provenance marker for ``element_name``, or None if absent/malformed.""" root = table.uns.get(_CENTROID_PROVENANCE_UNS_KEY) @@ -4534,12 +4545,12 @@ def _read_cached_centroids(sdata: SpatialData, element_name: str, table_name: st if _CENTROID_OBSM_KEY not in table.obsm: return None coords = np.asarray(table.obsm[_CENTROID_OBSM_KEY]) - if coords.ndim != 2 or coords.shape[0] != table.n_obs or coords.shape[1] < 2: + if not _valid_spatial_obsm(coords, table.n_obs): return None instance_key, mask = _region_mask_and_keys(table, element_name) if not mask.any(): return None - region_coords = coords[mask][:, :2].astype(float) + region_coords = coords[mask].astype(float) if np.isnan(region_coords).any(): return None # not (fully) populated for this element prov = _centroid_provenance(table, element_name) @@ -4562,7 +4573,7 @@ def _write_cached_centroids(sdata: SpatialData, element_name: str, table_name: s return if _CENTROID_OBSM_KEY in table.obsm: existing = np.asarray(table.obsm[_CENTROID_OBSM_KEY]) - if existing.ndim != 2 or existing.shape != (table.n_obs, 2): + if not _valid_spatial_obsm(existing, table.n_obs): return # don't clobber an incompatible obsm["spatial"] arr = existing.astype(float, copy=True) else: From fc3af02115e0d8d0b99cbeeea6b7bdd2cf1f5c2a Mon Sep 17 00:00:00 2001 From: anon Date: Mon, 8 Jun 2026 16:08:43 +0200 Subject: [PATCH 05/28] Add as_points fast mode: render shapes/labels as centroid dots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `render_shapes(..., as_points=True)` and `render_labels(..., as_points=True)` draw one dot per cell at its centroid instead of the full geometry / rasterized mask — a large speedup when only cell location matters. New `size=` controls the marker size. - Shared `_render_centroids_as_points` draws the scatter (via `_scatter_points`) and the legend/colorbar. The per-cell color vector is the *same* one the geometry/raster path computes (`_set_color_source_vec`), so colors match the full rendering exactly; only the apply step (scatter vs patches/imshow) differs. - Shapes: centroids from shapely `.centroid` of the (filtered) geometry, positionally aligned to the color vector, drawn in intrinsic coords via the element transform. Labels: centroids from `_get_or_compute_centroids` (regionprops, fast) reindexed to `instance_id`. Positions verified identical to `sd.get_centroids`. - `as_points` short-circuits before the geometry/raster path; outline_*, shape (shapes) and contour_px, outline_* (labels) are ignored with an info log. - Default (`as_points=False`) output is byte-identical to main. Tests: non-visual checks that centroids land exactly on `get_centroids` for both element types and that outline/shape are ignored without error. Note: as_points currently always uses the matplotlib scatter backend; routing through datashader for very large cell counts (and persisting the obsm cache to the user's object rather than show()'s working copy) are follow-ups. --- src/spatialdata_plot/pl/basic.py | 8 ++ src/spatialdata_plot/pl/render.py | 111 +++++++++++++++++++++++ src/spatialdata_plot/pl/render_params.py | 6 ++ tests/pl/test_render_labels.py | 14 +++ tests/pl/test_render_shapes.py | 24 +++++ 5 files changed, 163 insertions(+) diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index f864482c..1d2f409b 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -332,6 +332,8 @@ def render_shapes( colorbar_params: dict[str, object] | None = None, datashader_reduction: _DsReduction | None = None, transfunc: Callable[[float], float] | None = None, + as_points: bool = False, + size: float | int = 1.0, ) -> sd.SpatialData: """ Render shapes elements in SpatialData. @@ -515,6 +517,8 @@ def render_shapes( ds_reduction=param_values["ds_reduction"], colorbar=param_values["colorbar"], colorbar_params=param_values["colorbar_params"], + as_points=as_points, + size=size, panel_key=panel_key, ) n_steps += 1 @@ -953,6 +957,8 @@ def render_labels( table_layer: str | None = None, gene_symbols: str | None = None, transfunc: Callable[[float], float] | None = None, + as_points: bool = False, + size: float | int = 1.0, ) -> sd.SpatialData: """ Render labels elements in SpatialData. @@ -1101,6 +1107,8 @@ def render_labels( zorder=n_steps, colorbar=param_values["colorbar"], colorbar_params=param_values["colorbar_params"], + as_points=as_points, + size=size, panel_key=panel_key, ) n_steps += 1 diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 160b4087..9ebf625a 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -70,6 +70,7 @@ _get_colors_for_categorical_obs, _get_extent_and_range_for_datashader_canvas, _get_linear_colormap, + _get_or_compute_centroids, _hex_no_alpha, _join_table_for_element, _make_continuous_mappable, @@ -751,6 +752,35 @@ def _render_shapes( color_source_vector = color_source_vector.remove_unused_categories() shapes = gpd.GeoDataFrame(shapes, geometry="geometry") + + if render_params.as_points: + # Fast mode: draw one dot per shape at its centroid instead of its geometry. + logger.info("`as_points=True`: rendering shape centroids; `outline_*` and `shape` are ignored.") + centroids = shapes.geometry.centroid # intrinsic coords, positionally aligned to color_vector + _render_centroids_as_points( + ax, + x=centroids.x.to_numpy(), + y=centroids.y.to_numpy(), + color_vector=color_vector, + color_source_vector=color_source_vector, + cmap=render_params.cmap_params.cmap, + norm=norm, + na_color=render_params.cmap_params.na_color, + size=render_params.size, + alpha=render_params.fill_alpha, + zorder=render_params.zorder, + transform=trans_data, # intrinsic -> coordinate system -> display + adata=table, + col_for_color=col_for_color, + palette=palette, + fig_params=fig_params, + legend_params=legend_params, + colorbar=render_params.colorbar, + colorbar_params=render_params.colorbar_params, + colorbar_requests=colorbar_requests, + ) + return + # convert shapes if necessary if render_params.shape is not None: current_type = shapes["geometry"].type @@ -1086,6 +1116,55 @@ def _scatter_points( ) +def _render_centroids_as_points( + ax: matplotlib.axes.SubplotBase, + *, + x: Any, + y: Any, + color_vector: Any, + color_source_vector: pd.Series | None, + cmap: Colormap, + norm: Normalize | None, + na_color: Any, + size: float, + alpha: float, + zorder: int, + transform: Any, + adata: AnnData | None, + col_for_color: str | None, + palette: Any, + fig_params: FigParams, + legend_params: LegendParams, + colorbar: bool | str | None, + colorbar_params: dict[str, object] | None, + colorbar_requests: list[ColorbarSpec] | None, +) -> None: + """Render one dot per cell at ``(x, y)`` colored like the fill, with legend/colorbar. + + Shared "fast mode" draw for shapes/labels: ``color_vector`` is the same per-instance color + vector the geometry/raster path would use, so colors match the full rendering exactly. + """ + cax = _scatter_points( + ax, x, y, color_vector, size=size, cmap=cmap, norm=norm, alpha=alpha, trans_data=transform, zorder=zorder + ) + _add_legend_and_colorbar( + ax=ax, + cax=cax, + fig_params=fig_params, + adata=adata, + col_for_color=col_for_color, + color_source_vector=color_source_vector, + color_vector=color_vector, + palette=palette, + alpha=alpha, + na_color=na_color, + legend_params=legend_params, + colorbar=colorbar, + colorbar_params=colorbar_params, + colorbar_requests=colorbar_requests, + ) + + def _render_points( sdata: sd.SpatialData, render_params: PointsRenderParams, @@ -2272,6 +2351,38 @@ def _render_labels( if color_source_vector is None and render_params.transfunc is not None: color_vector = render_params.transfunc(color_vector) + if render_params.as_points: + # Fast mode: draw one dot per label at its centroid instead of the mask. Centroids come + # from `regionprops` (orders of magnitude faster than rasterizing) in coordinate-system + # coords, aligned to `instance_id` (and thus to `color_vector`). + logger.info("`as_points=True`: rendering label centroids; `contour_px` and `outline_*` are ignored.") + centroids = _get_or_compute_centroids( + sdata_filt, element, coordinate_system=coordinate_system, table_name=table_name + ).reindex(instance_id) + _render_centroids_as_points( + ax, + x=centroids["x"].to_numpy(), + y=centroids["y"].to_numpy(), + color_vector=color_vector, + color_source_vector=color_source_vector, + cmap=render_params.cmap_params.cmap, + norm=render_params.cmap_params.norm, + na_color=na_color, + size=render_params.size, + alpha=render_params.fill_alpha, + zorder=render_params.zorder, + transform=ax.transData, # centroids already in coordinate-system (= axes data) space + adata=table if table_name is not None else None, + col_for_color=col_for_color, + palette=palette, + fig_params=fig_params, + legend_params=legend_params, + colorbar=render_params.colorbar, + colorbar_params=render_params.colorbar_params, + colorbar_requests=colorbar_requests, + ) + return + def _draw_labels( seg_erosionpx: int | None, seg_boundaries: bool, diff --git a/src/spatialdata_plot/pl/render_params.py b/src/spatialdata_plot/pl/render_params.py index b021c305..748735b7 100644 --- a/src/spatialdata_plot/pl/render_params.py +++ b/src/spatialdata_plot/pl/render_params.py @@ -253,6 +253,9 @@ class ShapesRenderParams: table_name: str | None = None table_layer: str | None = None shape: Literal["circle", "hex", "visium_hex", "square"] | None = None + # Fast mode: render each shape as a single dot at its centroid instead of its geometry. + as_points: bool = False + size: float = 1.0 # marker size for as_points (matplotlib scatter ``s``) ds_reduction: _DsReduction | None = None colorbar: bool | str | None = "auto" colorbar_params: dict[str, object] | None = None @@ -328,6 +331,9 @@ class LabelsRenderParams: zorder: int = 0 colorbar: bool | str | None = "auto" colorbar_params: dict[str, object] | None = None + # Fast mode: render each label as a single dot at its centroid instead of the mask. + as_points: bool = False + size: float = 1.0 # marker size for as_points (matplotlib scatter ``s``) # Multi-panel color: when set, this render entry belongs to the panel identified by this # color key. ``None`` means the entry is shared across every panel (e.g. a background layer). panel_key: str | None = None diff --git a/tests/pl/test_render_labels.py b/tests/pl/test_render_labels.py index 1babf756..925985ec 100644 --- a/tests/pl/test_render_labels.py +++ b/tests/pl/test_render_labels.py @@ -605,3 +605,17 @@ def test_render_labels_color_list_creates_one_panel_per_key(sdata_blobs: Spatial assert len(axs) == 2 assert [ax.get_title() for ax in axs] == ["channel_0_sum", "channel_1_sum"] plt.close("all") + + +def test_render_labels_as_points_renders_centroids(sdata_blobs: SpatialData): + """as_points draws one dot per label at its centroid instead of the rasterized mask.""" + import spatialdata as sd + + fig, ax = plt.subplots() + sdata_blobs.pl.render_labels("blobs_labels", color="instance_id", as_points=True, size=50).pl.show(ax=ax) + offsets = np.asarray(ax.collections[0].get_offsets()) + ref = sd.get_centroids(sdata_blobs["blobs_labels"], coordinate_system="global").compute()[["x", "y"]] + assert len(offsets) == len(ref) + assert np.allclose(np.sort(offsets[:, 0]), np.sort(ref["x"].to_numpy()), atol=1e-6) + assert np.allclose(np.sort(offsets[:, 1]), np.sort(ref["y"].to_numpy()), atol=1e-6) + plt.close(fig) diff --git a/tests/pl/test_render_shapes.py b/tests/pl/test_render_shapes.py index f51cbf36..b30f13fe 100644 --- a/tests/pl/test_render_shapes.py +++ b/tests/pl/test_render_shapes.py @@ -1691,3 +1691,27 @@ def test_render_shapes_color_list_branches_are_independent(sdata_blobs: SpatialD # branch1 is still usable and unaffected assert len(branch1.plotting_tree) == base_steps + 2 plt.close("all") + + +def test_render_shapes_as_points_renders_centroids(sdata_blobs: SpatialData): + """as_points draws one dot per shape at its centroid (fast mode).""" + import spatialdata as sd + + fig, ax = plt.subplots() + sdata_blobs.pl.render_shapes("blobs_circles", as_points=True, size=50).pl.show(ax=ax) + offsets = np.asarray(ax.collections[0].get_offsets()) + ref = sd.get_centroids(sdata_blobs["blobs_circles"]).compute()[["x", "y"]] + assert len(offsets) == len(ref) + assert np.allclose(np.sort(offsets[:, 0]), np.sort(ref["x"].to_numpy()), atol=1e-6) + assert np.allclose(np.sort(offsets[:, 1]), np.sort(ref["y"].to_numpy()), atol=1e-6) + plt.close(fig) + + +def test_render_shapes_as_points_ignores_outline_and_shape(sdata_blobs: SpatialData): + """outline_* and shape are ignored under as_points and must not error.""" + fig, ax = plt.subplots() + sdata_blobs.pl.render_shapes( + "blobs_polygons", as_points=True, outline_alpha=1.0, outline_color="red", shape="square" + ).pl.show(ax=ax) + assert len(ax.collections) >= 1 # a scatter, not the patch collections of the geometry path + plt.close(fig) From 169d89740aea74d9479695c38663fe2537eb6751 Mon Sep 17 00:00:00 2001 From: anon Date: Mon, 8 Jun 2026 18:10:16 +0200 Subject: [PATCH 06/28] Fix as_points crash for labels without a color column `render_labels(element, as_points=True)` with no color crashed: `instance_id` (the raster's unique values) includes the background label `0`, which has no centroid, and the literal/no-color color vector is sized to the raster (not per-instance), so `ax.scatter` got mismatched `c` vs `x`/`y`. Drop the background label from the rendered instances and align the per-cell color: for data-driven color the vector is already per-instance and is subset to match; for the literal/no-color path it is replaced with one na/literal color per centroid. Data-driven (categorical/continuous) renders are unchanged and still land exactly on `get_centroids`. Adds a regression test for the no-color labels case. --- src/spatialdata_plot/pl/render.py | 23 +++++++++++++++++++---- tests/pl/test_render_labels.py | 12 ++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 9ebf625a..01d42b24 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -2354,17 +2354,32 @@ def _render_labels( if render_params.as_points: # Fast mode: draw one dot per label at its centroid instead of the mask. Centroids come # from `regionprops` (orders of magnitude faster than rasterizing) in coordinate-system - # coords, aligned to `instance_id` (and thus to `color_vector`). + # coords. logger.info("`as_points=True`: rendering label centroids; `contour_px` and `outline_*` are ignored.") + # `instance_id` may include the background label 0 (which has no centroid); drop it. + keep = instance_id != 0 + point_ids = instance_id[keep] centroids = _get_or_compute_centroids( sdata_filt, element, coordinate_system=coordinate_system, table_name=table_name - ).reindex(instance_id) + ).reindex(point_ids) + # Align the per-cell color to the rendered centroids. For data-driven color the vector is + # already per-instance (paired with `instance_id`); for the literal/no-color path it is + # not, so fall back to one na/literal color per centroid. + point_color_vector = np.asarray(color_vector) + point_color_source_vector = color_source_vector + if len(point_color_vector) == len(instance_id): + point_color_vector = point_color_vector[keep] + if point_color_source_vector is not None: + point_color_source_vector = point_color_source_vector[keep] + else: + point_color_vector = np.asarray([na_color.get_hex_with_alpha()] * len(point_ids)) + point_color_source_vector = None _render_centroids_as_points( ax, x=centroids["x"].to_numpy(), y=centroids["y"].to_numpy(), - color_vector=color_vector, - color_source_vector=color_source_vector, + color_vector=point_color_vector, + color_source_vector=point_color_source_vector, cmap=render_params.cmap_params.cmap, norm=render_params.cmap_params.norm, na_color=na_color, diff --git a/tests/pl/test_render_labels.py b/tests/pl/test_render_labels.py index 925985ec..11adf30f 100644 --- a/tests/pl/test_render_labels.py +++ b/tests/pl/test_render_labels.py @@ -619,3 +619,15 @@ def test_render_labels_as_points_renders_centroids(sdata_blobs: SpatialData): assert np.allclose(np.sort(offsets[:, 0]), np.sort(ref["x"].to_numpy()), atol=1e-6) assert np.allclose(np.sort(offsets[:, 1]), np.sort(ref["y"].to_numpy()), atol=1e-6) plt.close(fig) + + +def test_render_labels_as_points_without_color(sdata_blobs: SpatialData): + """as_points must not crash without a color column; the background label (0) is excluded.""" + import spatialdata as sd + + fig, ax = plt.subplots() + sdata_blobs.pl.render_labels("blobs_labels", as_points=True).pl.show(ax=ax) + offsets = np.asarray(ax.collections[0].get_offsets()) + n_cells = len(sd.get_centroids(sdata_blobs["blobs_labels"], coordinate_system="global").compute()) + assert len(offsets) == n_cells # one dot per cell, no spurious background point + plt.close(fig) From 4678297b0dc96f19afad269252299123cff4550b Mon Sep 17 00:00:00 2001 From: anon Date: Mon, 8 Jun 2026 18:28:00 +0200 Subject: [PATCH 07/28] Compute label centroids with a streaming, out-of-core bincount aggregator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `regionprops` reduction in `_compute_label_centroids` with an additive bincount aggregator that streams the labels raster block by block — one dask chunk (or bounded numpy row-block) in memory at a time — accumulating per-label `count`/`sum_x`/`sum_y`. This is what makes the feature usable at Xenium scale: - Out-of-core: peak memory is one chunk + O(n_labels) accumulators, NOT the whole raster (measured: 9 MB peak streaming a 268 MB mask). `regionprops` needs the full array materialized and OOMs on large morphology masks. - Scales in cell count: 500k+ labels are just array indexing (562k labels in ~1.4 s with 13.5 MB of accumulators); `regionprops`' per-label table does not. - Faster than `regionprops` (~1.3-1.6x) on in-memory rasters. - Exact across chunk boundaries (additive reduction) — verified numpy == dask-chunked, and identical to `sd.get_centroids`. - `count` is the cell area, a free by-product (ready for footprint-based dot sizing). Drops the `regionprops_table` import; adds `slices_from_chunks`. Adds a unit test locking the chunk-exact, out-of-core, area-correct behavior. Note: the chunk loop is currently sequential; parallelizing the per-chunk partials (dask map_blocks + tree-reduce) is a future speedup. --- src/spatialdata_plot/pl/utils.py | 72 ++++++++++++++++++++++++-------- tests/pl/test_utils.py | 16 +++++++ 2 files changed, 71 insertions(+), 17 deletions(-) diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 78cb7301..6fd2f39e 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -26,6 +26,7 @@ import spatialdata as sd from anndata import AnnData from cycler import Cycler, cycler +from dask.array.core import slices_from_chunks from datashader.core import Canvas from geopandas import GeoDataFrame from matplotlib import colors, patheffects, rcParams @@ -55,7 +56,6 @@ from scipy.spatial import ConvexHull from shapely.errors import GEOSException from skimage.color import label2rgb -from skimage.measure import regionprops_table from skimage.morphology import erosion, footprint_rectangle from skimage.util import map_array from spatialdata import ( @@ -4445,21 +4445,62 @@ def _transform_carrier(element: SpatialElement) -> Any: return element -def _compute_label_centroids(element: DataArray | DataTree) -> pd.DataFrame: - """Per-label centroids in the element's *intrinsic* coordinates via skimage ``regionprops``. +def _stream_label_centroid_stats(data: Any) -> tuple[ArrayLike, ArrayLike, ArrayLike, ArrayLike]: + """Per-label ``(labels, mean_x_index, mean_y_index, area)`` via a streaming bincount aggregator. + + Streams the raster block by block — one chunk in memory at a time for a dask array, a + bounded row-block at a time for a numpy array — accumulating per-label ``count`` (= area), + ``sum_x`` and ``sum_y``. The reduction is additive, so it is exact across block boundaries + and uses only O(n_labels) memory regardless of raster size. Background label 0 is excluded. + """ + n_rows, n_cols = data.shape + is_dask = hasattr(data, "chunks") and not isinstance(data, np.ndarray) + if is_dask: + n_labels = int(data.max().compute()) + 1 + block_slices = slices_from_chunks(data.chunks) + else: + data = np.asarray(data) + n_labels = int(data.max()) + 1 + # bound the per-block coordinate-weight arrays to ~8M pixels + step = max(1, min(n_rows, (8 << 20) // max(1, n_cols))) + block_slices = [(slice(r0, min(r0 + step, n_rows)), slice(0, n_cols)) for r0 in range(0, n_rows, step)] + + count = np.zeros(n_labels, dtype=np.float64) + sum_x = np.zeros(n_labels, dtype=np.float64) + sum_y = np.zeros(n_labels, dtype=np.float64) + for row_sl, col_sl in block_slices: + block = data[row_sl, col_sl] + block = np.asarray(block.compute() if hasattr(block, "compute") else block) + block_rows, block_cols = block.shape + flat = block.reshape(-1) + cols = np.tile(np.arange(col_sl.start, col_sl.start + block_cols, dtype=np.float64), block_rows) + rows = np.repeat(np.arange(row_sl.start, row_sl.start + block_rows, dtype=np.float64), block_cols) + count += np.bincount(flat, minlength=n_labels) + sum_x += np.bincount(flat, weights=cols, minlength=n_labels) + sum_y += np.bincount(flat, weights=rows, minlength=n_labels) + + labels = np.flatnonzero(count) + labels = labels[labels != 0] # drop background and absent labels + return labels, sum_x[labels] / count[labels], sum_y[labels] / count[labels], count[labels] + - ``regionprops`` does the per-label reduction in a single C pass, orders of magnitude - faster than ``spatialdata.get_centroids`` on labels (which scans the raster row/column-wise - and scales with raster area). Returns intrinsic coords; the caller maps them to a target - coordinate system on demand. +def _compute_label_centroids(element: DataArray | DataTree) -> pd.DataFrame: + """Per-label centroids in the element's *intrinsic* coordinates via a streaming bincount aggregator. + + The centroid is the mean pixel coordinate per label, computed with an additive + ``bincount`` aggregator (``count``/``sum_x``/``sum_y`` per label). ``count`` is the cell + **area** — a free by-product. The aggregator streams the (possibly huge, dask-backed) + raster **block by block**, holding only one chunk plus O(n_labels) accumulators, so it + scales to Xenium-size masks with hundreds of thousands of cells where ``regionprops`` + (whole-array, per-label table) would run out of memory. Returns intrinsic coords; the + caller maps them to a target coordinate system on demand. """ raster = _transform_carrier(element) - values = np.asarray(raster.data) # materialize the intrinsic pixel grid - props = regionprops_table(values, properties=("label", "centroid")) + labels, x_idx, y_idx, _area = _stream_label_centroid_stats(raster.data) - # regionprops excludes background (label 0); centroid-0 = row (y), centroid-1 = col (x) - # as 0-based fractional indices. Map them onto the raster's intrinsic coordinate arrays - # (spatialdata uses pixel-center coords 0.5, 1.5, ... and possibly non-unit spacing). + # bincount gives mean 0-based pixel indices; map them onto the raster's intrinsic + # coordinate arrays (spatialdata uses pixel-center coords 0.5, 1.5, ... and possibly + # non-unit spacing). def _index_to_coord(idx: ArrayLike, coord: ArrayLike) -> ArrayLike: spacing = (coord[1] - coord[0]) if len(coord) > 1 else 1.0 return coord[0] + idx * spacing @@ -4467,11 +4508,8 @@ def _index_to_coord(idx: ArrayLike, coord: ArrayLike) -> ArrayLike: xcoord = np.asarray(raster.coords["x"].values) ycoord = np.asarray(raster.coords["y"].values) return pd.DataFrame( - { - "x": _index_to_coord(props["centroid-1"], xcoord), - "y": _index_to_coord(props["centroid-0"], ycoord), - }, - index=props["label"], + {"x": _index_to_coord(x_idx, xcoord), "y": _index_to_coord(y_idx, ycoord)}, + index=labels, ) diff --git a/tests/pl/test_utils.py b/tests/pl/test_utils.py index d5e51a64..17ead13f 100644 --- a/tests/pl/test_utils.py +++ b/tests/pl/test_utils.py @@ -518,6 +518,22 @@ def test_centroids_match_get_centroids(self, sdata_blobs: SpatialData, element: assert list(mine.index) == list(ref.index) assert np.allclose(mine.values, ref.values, atol=1e-9) + def test_label_centroids_stream_out_of_core_and_chunk_exact(self, sdata_blobs: SpatialData): + import dask.array as da + + from spatialdata_plot.pl.utils import _stream_label_centroid_stats + + lab = np.asarray(sdata_blobs["blobs_labels"].data.compute()) + l_np, x_np, y_np, a_np = _stream_label_centroid_stats(lab) + # many small chunks -> exercises block streaming + global offsets; must be exact. + l_da, x_da, y_da, a_da = _stream_label_centroid_stats(da.from_array(lab, chunks=(64, 64))) + assert np.array_equal(l_np, l_da) + assert np.allclose(x_np, x_da) and np.allclose(y_np, y_da) + assert np.array_equal(a_np, a_da) + # area is the per-label pixel count; background label 0 is excluded. + assert np.array_equal(a_np.astype(int), np.bincount(lab.ravel())[l_np]) + assert 0 not in l_np + def test_cache_is_coordinate_system_independent(self, sdata_blobs: SpatialData): # one intrinsic cache serves every coordinate system: add a second CS and confirm both # match get_centroids while only one obsm["spatial"] is written. From f8918fba50d2aad7650a7e26513be7ac766a3ec7 Mon Sep 17 00:00:00 2001 From: anon Date: Tue, 9 Jun 2026 23:27:24 +0200 Subject: [PATCH 08/28] Simplify _render_centroids_as_points: take render_params, not 6 unpacked fields The fast-mode draw wrapper read cmap/size/alpha/zorder/colorbar/colorbar_params straight off render_params at both call sites. Pass the render_params dataclass instead and read them internally; only the genuinely per-branch values (x/y/color vectors, norm, na_color, transform, adata, palette, col_for_color) stay explicit. Signature 20->15 params; both as_points call sites lose the restated render_params plumbing. --- src/spatialdata_plot/pl/render.py | 44 ++++++++++++++----------------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 300277ac..ae745a4d 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -759,24 +759,19 @@ def _render_shapes( centroids = shapes.geometry.centroid # intrinsic coords, positionally aligned to color_vector _render_centroids_as_points( ax, + render_params, x=centroids.x.to_numpy(), y=centroids.y.to_numpy(), color_vector=color_vector, color_source_vector=color_source_vector, - cmap=render_params.cmap_params.cmap, norm=norm, na_color=render_params.cmap_params.na_color, - size=render_params.size, - alpha=render_params.fill_alpha, - zorder=render_params.zorder, transform=trans_data, # intrinsic -> coordinate system -> display adata=table, col_for_color=col_for_color, palette=palette, fig_params=fig_params, legend_params=legend_params, - colorbar=render_params.colorbar, - colorbar_params=render_params.colorbar_params, colorbar_requests=colorbar_requests, ) return @@ -1118,34 +1113,40 @@ def _scatter_points( def _render_centroids_as_points( ax: matplotlib.axes.SubplotBase, + render_params: ShapesRenderParams | LabelsRenderParams, *, x: Any, y: Any, color_vector: Any, color_source_vector: pd.Series | None, - cmap: Colormap, norm: Normalize | None, na_color: Any, - size: float, - alpha: float, - zorder: int, transform: Any, adata: AnnData | None, col_for_color: str | None, palette: Any, fig_params: FigParams, legend_params: LegendParams, - colorbar: bool | str | None, - colorbar_params: dict[str, object] | None, colorbar_requests: list[ColorbarSpec] | None, ) -> None: """Render one dot per cell at ``(x, y)`` colored like the fill, with legend/colorbar. - Shared "fast mode" draw for shapes/labels: ``color_vector`` is the same per-instance color - vector the geometry/raster path would use, so colors match the full rendering exactly. + Shared "fast mode" draw for shapes/labels: cmap/size/alpha/zorder/colorbar come off + ``render_params``; ``color_vector`` is the same per-instance color vector the geometry/raster + path would use, so colors match the full rendering exactly. ``norm``/``na_color`` stay explicit + because they differ between the shapes (locally adjusted) and labels paths. """ cax = _scatter_points( - ax, x, y, color_vector, size=size, cmap=cmap, norm=norm, alpha=alpha, trans_data=transform, zorder=zorder + ax, + x, + y, + color_vector, + size=render_params.size, + cmap=render_params.cmap_params.cmap, + norm=norm, + alpha=render_params.fill_alpha, + trans_data=transform, + zorder=render_params.zorder, ) _add_legend_and_colorbar( ax=ax, @@ -1156,11 +1157,11 @@ def _render_centroids_as_points( color_source_vector=color_source_vector, color_vector=color_vector, palette=palette, - alpha=alpha, + alpha=render_params.fill_alpha, na_color=na_color, legend_params=legend_params, - colorbar=colorbar, - colorbar_params=colorbar_params, + colorbar=render_params.colorbar, + colorbar_params=render_params.colorbar_params, colorbar_requests=colorbar_requests, ) @@ -2380,24 +2381,19 @@ def _render_labels( point_color_source_vector = None _render_centroids_as_points( ax, + render_params, x=centroids["x"].to_numpy(), y=centroids["y"].to_numpy(), color_vector=point_color_vector, color_source_vector=point_color_source_vector, - cmap=render_params.cmap_params.cmap, norm=render_params.cmap_params.norm, na_color=na_color, - size=render_params.size, - alpha=render_params.fill_alpha, - zorder=render_params.zorder, transform=centroid_trans, # scale0 intrinsic coords -> coordinate system -> display adata=table if table_name is not None else None, col_for_color=col_for_color, palette=palette, fig_params=fig_params, legend_params=legend_params, - colorbar=render_params.colorbar, - colorbar_params=render_params.colorbar_params, colorbar_requests=colorbar_requests, ) return From 79892ee401168108fb3db25e6196477573c4be1b Mon Sep 17 00:00:00 2001 From: anon Date: Tue, 9 Jun 2026 23:34:33 +0200 Subject: [PATCH 09/28] Tidy labels as_points color alignment Collapse the per-centroid color alignment (drop the unconditional asarray pre-init and the nested None-guard into a single if/else + ternary), and drop a redundant np.asarray around the already-ndarray point_ids in the reindex. Behavior unchanged. --- src/spatialdata_plot/pl/render.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index ae745a4d..cb395d50 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -2364,18 +2364,16 @@ def _render_labels( centroids = _compute_element_measurements(sdata_filt, element) # scale0 intrinsic [x, y, area] # Coerce point_ids to the label-value dtype so str/object ids from `table.obs` (e.g. Xenium # readers) match the integer raster labels instead of silently reindexing to NaN. - centroids = centroids.reindex(np.asarray(point_ids).astype(centroids.index.dtype, copy=False)) + centroids = centroids.reindex(point_ids.astype(centroids.index.dtype, copy=False)) # Transform built from scale0 (matching the centroids), not the possibly-rasterized `label`. _, centroid_trans = _prepare_transformation(_get_top_data_array(sdata_filt[element]), coordinate_system, ax) - # Align the per-cell color to the rendered centroids. For data-driven color the vector is - # already per-instance (paired with `instance_id`); for the literal/no-color path it is - # not, so fall back to one na/literal color per centroid. - point_color_vector = np.asarray(color_vector) - point_color_source_vector = color_source_vector - if len(point_color_vector) == len(instance_id): - point_color_vector = point_color_vector[keep] - if point_color_source_vector is not None: - point_color_source_vector = point_color_source_vector[keep] + # Align the per-cell color to the rendered centroids. Data-driven color is already + # per-instance (paired with `instance_id`); the literal/no-color path is not, so fall back + # to one na/literal color per centroid. + color_vec = np.asarray(color_vector) + if len(color_vec) == len(instance_id): + point_color_vector = color_vec[keep] + point_color_source_vector = None if color_source_vector is None else color_source_vector[keep] else: point_color_vector = np.asarray([na_color.get_hex_with_alpha()] * len(point_ids)) point_color_source_vector = None From 85692e91133bab28053269972b2d820f397da5fd Mon Sep 17 00:00:00 2001 From: anon Date: Tue, 9 Jun 2026 23:40:29 +0200 Subject: [PATCH 10/28] Trim verbose as_points comments to the load-bearing invariants --- src/spatialdata_plot/pl/render.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index cb395d50..264e17ba 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -2353,23 +2353,16 @@ def _render_labels( color_vector = render_params.transfunc(color_vector) if render_params.as_points: - # Fast mode: draw one dot per label at its full-resolution (scale0) centroid instead of the - # rasterized mask. The streaming bincount aggregator computes the centroids; we then draw - # them with the scale0 element's own coordinate-system transform so they land where the cells - # actually are (independent of any rasterization applied to the rendered `label` above). + # Fast mode: one dot per label at its full-resolution (scale0) centroid, not the rasterized mask. logger.info("`as_points=True`: rendering label centroids; `contour_px` and `outline_*` are ignored.") - # `instance_id` may include the background label 0 (which has no centroid); drop it. - keep = instance_id != 0 + keep = instance_id != 0 # background label 0 has no centroid point_ids = instance_id[keep] centroids = _compute_element_measurements(sdata_filt, element) # scale0 intrinsic [x, y, area] - # Coerce point_ids to the label-value dtype so str/object ids from `table.obs` (e.g. Xenium - # readers) match the integer raster labels instead of silently reindexing to NaN. + # coerce so str/object table ids (e.g. Xenium) match the integer raster labels instead of NaN centroids = centroids.reindex(point_ids.astype(centroids.index.dtype, copy=False)) - # Transform built from scale0 (matching the centroids), not the possibly-rasterized `label`. + # transform from scale0 (matching the centroids), NOT the possibly-rasterized `label` _, centroid_trans = _prepare_transformation(_get_top_data_array(sdata_filt[element]), coordinate_system, ax) - # Align the per-cell color to the rendered centroids. Data-driven color is already - # per-instance (paired with `instance_id`); the literal/no-color path is not, so fall back - # to one na/literal color per centroid. + # data-driven color is per-instance; literal/no-color is not -> one na color per centroid color_vec = np.asarray(color_vector) if len(color_vec) == len(instance_id): point_color_vector = color_vec[keep] From b087c1cc63b6ff93c8382cc2545c18d65bd9e0f5 Mon Sep 17 00:00:00 2001 From: anon Date: Wed, 10 Jun 2026 00:09:20 +0200 Subject: [PATCH 11/28] Make labels as_points fast: centroids on the rendered raster, not scale0 Benchmark showed labels as_points was 16-34x SLOWER than the normal render because it recomputed full-resolution scale0 centroids while the normal path downsamples + imshows. Compute centroids on the already-rendered (downsampled) raster and draw with its trans_data instead: 671k cells goes 19.7s -> 0.88s (now ~parity with imshow). Centroid error is sub-pixel at display resolution; position tests updated to display-space within a few-px tolerance. --- src/spatialdata_plot/pl/render.py | 22 +++++++++++++++------- tests/pl/test_render_labels.py | 24 ++++++++++++++---------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 264e17ba..8074cbbc 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -63,7 +63,6 @@ _build_shape_patches, _check_obs_var_shadow, _color_vector_to_rgba, - _compute_element_measurements, _convert_shapes, _datashader_canvas_from_dataframe, _decorate_axs, @@ -78,11 +77,13 @@ _maybe_set_colors, _mpl_ax_contains_elements, _multiscale_to_spatial_image, + _pixel_to_coord, _prepare_cmap_norm, _prepare_transformation, _rasterize_if_necessary, _rasterize_if_necessary_datashader, _set_color_source_vec, + _stream_label_centroid_stats, _validate_polygons, ) @@ -2353,15 +2354,22 @@ def _render_labels( color_vector = render_params.transfunc(color_vector) if render_params.as_points: - # Fast mode: one dot per label at its full-resolution (scale0) centroid, not the rasterized mask. + # Fast mode: one dot per label at its centroid. Compute on the *rendered* raster (already + # downsampled to ~display resolution above) and draw with its `trans_data`, so this is cheap + # and the dots land where the cells are. Centroid error is sub-pixel at display resolution. logger.info("`as_points=True`: rendering label centroids; `contour_px` and `outline_*` are ignored.") keep = instance_id != 0 # background label 0 has no centroid point_ids = instance_id[keep] - centroids = _compute_element_measurements(sdata_filt, element) # scale0 intrinsic [x, y, area] + labels, x_idx, y_idx, _area = _stream_label_centroid_stats(label.data) + centroids = pd.DataFrame( + { + "x": _pixel_to_coord(x_idx, label.coords["x"].values), + "y": _pixel_to_coord(y_idx, label.coords["y"].values), + }, + index=labels, + ) # coerce so str/object table ids (e.g. Xenium) match the integer raster labels instead of NaN - centroids = centroids.reindex(point_ids.astype(centroids.index.dtype, copy=False)) - # transform from scale0 (matching the centroids), NOT the possibly-rasterized `label` - _, centroid_trans = _prepare_transformation(_get_top_data_array(sdata_filt[element]), coordinate_system, ax) + centroids = centroids.reindex(point_ids.astype(labels.dtype, copy=False)) # data-driven color is per-instance; literal/no-color is not -> one na color per centroid color_vec = np.asarray(color_vector) if len(color_vec) == len(instance_id): @@ -2379,7 +2387,7 @@ def _render_labels( color_source_vector=point_color_source_vector, norm=render_params.cmap_params.norm, na_color=na_color, - transform=centroid_trans, # scale0 intrinsic coords -> coordinate system -> display + transform=trans_data, # rendered-raster intrinsic coords -> coordinate system -> display adata=table if table_name is not None else None, col_for_color=col_for_color, palette=palette, diff --git a/tests/pl/test_render_labels.py b/tests/pl/test_render_labels.py index e82416a0..6eada3f6 100644 --- a/tests/pl/test_render_labels.py +++ b/tests/pl/test_render_labels.py @@ -608,16 +608,20 @@ def test_render_labels_color_list_creates_one_panel_per_key(sdata_blobs: Spatial def test_render_labels_as_points_renders_centroids(sdata_blobs: SpatialData): - """as_points draws one dot per label at its centroid instead of the rasterized mask.""" + """as_points draws one dot per label near its centroid. Centroids are computed on the rendered + (possibly downsampled) raster, so positions are checked in display space within a few-pixel + rasterization tolerance rather than against the exact full-resolution centroid.""" import spatialdata as sd fig, ax = plt.subplots() sdata_blobs.pl.render_labels("blobs_labels", color="instance_id", as_points=True, size=50).pl.show(ax=ax) - offsets = np.asarray(ax.collections[0].get_offsets()) - ref = sd.get_centroids(sdata_blobs["blobs_labels"], coordinate_system="global").compute()[["x", "y"]] - assert len(offsets) == len(ref) - assert np.allclose(np.sort(offsets[:, 0]), np.sort(ref["x"].to_numpy()), atol=1e-6) - assert np.allclose(np.sort(offsets[:, 1]), np.sort(ref["y"].to_numpy()), atol=1e-6) + coll = ax.collections[0] + dots = coll.get_offset_transform().transform(np.asarray(coll.get_offsets())) # display px + ref_world = sd.get_centroids(sdata_blobs["blobs_labels"], coordinate_system="global").compute()[["x", "y"]] + ref = ax.transData.transform(ref_world.to_numpy()) + assert len(dots) == len(ref) + od, oe = np.lexsort((dots[:, 1], dots[:, 0])), np.lexsort((ref[:, 1], ref[:, 0])) + assert np.allclose(dots[od], ref[oe], atol=3.0) # within a few display px of the true centroid plt.close(fig) @@ -634,9 +638,9 @@ def test_render_labels_as_points_without_color(sdata_blobs: SpatialData): def test_render_labels_as_points_applies_non_identity_transform(sdata_blobs: SpatialData): - """Regression guard: under a non-identity element->CS transform the dots must land at the - cells' coordinate-system positions. Offsets stay in scale0 space, so correctness lives in the - transform applied by the scatter; check it in display space.""" + """Regression guard: under a non-identity element->CS transform the dots must land at the cells' + coordinate-system positions (in display space). A wrong transform would be off by the scale + factor (hundreds of px); the rendered-raster centroid is correct within a few px.""" import spatialdata as sd from spatialdata.transformations import Scale, set_transformation @@ -651,5 +655,5 @@ def test_render_labels_as_points_applies_non_identity_transform(sdata_blobs: Spa expected_display = ax.transData.transform(cs) # where the cells truly are, in display pixels order_d = np.lexsort((dots_display[:, 1], dots_display[:, 0])) order_e = np.lexsort((expected_display[:, 1], expected_display[:, 0])) - assert np.allclose(dots_display[order_d], expected_display[order_e], atol=1e-2) + assert np.allclose(dots_display[order_d], expected_display[order_e], atol=3.0) plt.close(fig) From 57bcf8db50b5bdfed36a0a634239c031995bd1b6 Mon Sep 17 00:00:00 2001 From: anon Date: Wed, 10 Jun 2026 01:19:41 +0200 Subject: [PATCH 12/28] Fast axis-bounds in show(): skip per-geometry transform when axis-aligned show()'s get_extent(exact=True) transforms EVERY shapes/points geometry into the coordinate system just to take a bounding box - the dominant cost for large shape collections (~85% of a 5.5M-shape render), unrelated to what is drawn. Add a self-contained get_extent_fast (drop-in for spatialdata.get_extent) that, for axis-aligned transforms (scale/flip/90deg/swap + translation - all real Visium/Xenium data), transforms only the 4 bounding-box corners and reads the intrinsic bounds vectorised (no per-geometry .apply(is_empty)). Proven identical to the exact extent for such transforms (spatialdata's own get_extent docstring notes this); falls back to spatialdata's get_extent for rotation/shear, for anisotropically-scaled circles (radius->ellipse divergence), and for images/labels (whose get_extent is already a cheap corner transform). Measured on real Visium HD render_shapes(as_points=True): 351k 8.5s->1.8s (4.7x), 5.48M 129s->24s (5.5x); the full (non-as_points) render benefits too. The whole block is isolated so it can be lifted into spatialdata's get_extent verbatim (see issue #706). Tests assert it matches get_extent across scale/flip/ rotation/shear for circles and polygons. --- src/spatialdata_plot/pl/basic.py | 6 +- src/spatialdata_plot/pl/utils.py | 122 +++++++++++++++++++++++++++++++ tests/pl/test_utils.py | 51 +++++++++++++ 3 files changed, 178 insertions(+), 1 deletion(-) diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index 1d2f409b..0de6bed9 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -78,6 +78,7 @@ _validate_shape_render_params, _validate_show_parameters, _verify_plotting_tree, + get_extent_fast, save_fig, ) @@ -1828,7 +1829,10 @@ def _draw_colorbar( "all geometries are empty. Drop the element or restore at least one non-empty geometry." ) - extent = get_extent( + # `get_extent_fast` skips transforming every shapes/points geometry when the element's + # transform is axis-aligned (the common scale+translation case); identical result, but + # avoids the O(N-geometries) bottleneck for large shape collections. + extent = get_extent_fast( sdata, coordinate_system=cs, has_images=has_images and wants_images, diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index b6eab503..b65afac4 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -72,6 +72,7 @@ from spatialdata.models import ( Image2DModel, Labels2DModel, + PointsModel, ShapesModel, SpatialElement, get_model, @@ -4728,3 +4729,124 @@ def measure_obs( table = _resolve_measure_table(target, name, table_name) _measure_into_table(target, name, table, centroids=centroids, area=area, diameter=diameter) return None if inplace else target + + +# --- Fast extent for axis-aligned transforms ------------------------------------------------------ +# `pl.show()` computes axis bounds via spatialdata's `get_extent(..., exact=True)`, which transforms +# EVERY shapes/points geometry into the coordinate system (per-geometry, O(N)) just to take a bounding +# box — the dominant cost when rendering large shape collections. When the element's transform is +# axis-aligned (scale / flip / 90deg rotation / axis swap + translation), the exact extent equals the +# bounding box of the *transformed corners* (spatialdata's own get_extent docstring notes this), so we +# can transform 4 corners instead of N geometries, and read the intrinsic bounds vectorised (avoiding +# spatialdata's per-geometry `.apply(is_empty)` filter). This whole block is self-contained so it can +# be lifted into spatialdata's `get_extent` verbatim; it falls back to `get_extent` for rotation/shear. + + +def _is_axis_aligned(linear2x2: ArrayLike, *, rtol: float = 1e-9) -> bool: + """Whether a 2x2 linear map sends axis-aligned boxes to axis-aligned boxes. + + True for a *monomial matrix* (at most one non-zero per row and per column): scale, axis flips, + 90/180/270-degree rotations and axis swaps. For such maps the exact extent equals the bounding box + of the transformed corners. A relative tolerance ignores floating-point noise in the affine matrix. + """ + m = np.abs(np.asarray(linear2x2, dtype=float)) + nz = m > rtol * (m.max() or 1.0) + return bool((nz.sum(0) <= 1).all() and (nz.sum(1) <= 1).all() and int(nz.sum()) == m.shape[0]) + + +def _intrinsic_xy_bounds(element: Any) -> tuple[float, float, float, float] | None: + """``(xmin, ymin, xmax, ymax)`` of a shapes/points element in its intrinsic coords, vectorised. + + Circles (``Point`` + ``radius``) expand by the radius; polygons use the vectorised ``.bounds``; + points read the x/y columns. NaN bounds of empty geometries are skipped by min/max, so no + per-geometry empty filter is needed. Returns ``None`` for unsupported element types. + """ + model = get_model(element) + if model is ShapesModel: + geom = element.geometry + if (geom.geom_type == "Point").all(): # circles + x, y = geom.x.to_numpy(), geom.y.to_numpy() + r = np.asarray(element["radius"], dtype=float) + return float(np.nanmin(x - r)), float(np.nanmin(y - r)), float(np.nanmax(x + r)), float(np.nanmax(y + r)) + b = geom.bounds # vectorised; columns minx/miny/maxx/maxy + return float(b["minx"].min()), float(b["miny"].min()), float(b["maxx"].max()), float(b["maxy"].max()) + if model is PointsModel: + x, y = element["x"], element["y"] + return float(x.min().compute()), float(y.min().compute()), float(x.max().compute()), float(y.max().compute()) + return None + + +def _element_extent_fast(element: Any, coordinate_system: str) -> dict[str, tuple[float, float]] | None: + """Extent of one shapes/points element in ``coordinate_system`` via corner-transform. + + Returns ``None`` (signalling the caller to fall back to ``get_extent``) when the element type is + unsupported or the transform is not axis-aligned (rotation/shear, where the cheap path would + over-estimate). For circles it also falls back under an *anisotropic* linear map: a scaled circle + is an ellipse, but spatialdata stores a single uniformly-scaled radius, so the cheap and exact + extents only agree when the scale is isotropic. + """ + model = get_model(element) + if model not in (ShapesModel, PointsModel): + return None + matrix = get_transformation(element, get_all=True)[coordinate_system].to_affine_matrix(("x", "y"), ("x", "y")) + affine = matrix[:2, :2] + if not _is_axis_aligned(affine): + return None + if model is ShapesModel and bool((element.geometry.geom_type == "Point").all()): # circles + nz = np.abs(affine)[np.abs(affine) > 1e-9 * (np.abs(affine).max() or 1.0)] + if not np.allclose(nz, nz[0]): # anisotropic -> radius handling diverges from spatialdata + return None + bounds = _intrinsic_xy_bounds(element) + if bounds is None: + return None + xmin, ymin, xmax, ymax = bounds + corners = np.array([[xmin, ymin], [xmax, ymin], [xmin, ymax], [xmax, ymax]]) + tc = corners @ affine.T + matrix[:2, 2] + return {"x": (float(tc[:, 0].min()), float(tc[:, 0].max())), "y": (float(tc[:, 1].min()), float(tc[:, 1].max()))} + + +def get_extent_fast( + sdata: SpatialData, + coordinate_system: str, + *, + has_images: bool = True, + has_labels: bool = True, + has_points: bool = True, + has_shapes: bool = True, + elements: list[str] | None = None, +) -> dict[str, tuple[float, float]]: + """Drop-in replacement for spatialdata ``get_extent(sdata, ...)`` with a fast path for shapes/points. + + Shapes/points with axis-aligned transforms get the corner-transform extent (identical result, no + per-geometry transform); everything else (rotation/shear, images, labels) delegates to spatialdata's + ``get_extent``. The union semantics match spatialdata's ``get_extent``. + """ + include = {"images": has_images, "labels": has_labels, "points": has_points, "shapes": has_shapes} + element_dicts = {"images": sdata.images, "labels": sdata.labels, "points": sdata.points, "shapes": sdata.shapes} + mins: dict[str, list[float]] = {"x": [], "y": []} + maxs: dict[str, list[float]] = {"x": [], "y": []} + for etype, edict in element_dicts.items(): + if not include[etype]: + continue + for name, element in edict.items(): + if elements is not None and name not in elements: + continue + if coordinate_system not in get_transformation(element, get_all=True): + continue + ext = _element_extent_fast(element, coordinate_system) if etype in ("shapes", "points") else None + if ext is None: # rotation/shear, image/label (already cheap), or unsupported + ext = get_extent(element, coordinate_system=coordinate_system) + for ax in ("x", "y"): + mins[ax].append(ext[ax][0]) + maxs[ax].append(ext[ax][1]) + if not mins["x"]: # nothing matched -> defer to spatialdata (preserves its error behaviour) + return get_extent( + sdata, + coordinate_system=coordinate_system, + has_images=has_images, + has_labels=has_labels, + has_points=has_points, + has_shapes=has_shapes, + elements=elements, + ) + return {ax: (min(mins[ax]), max(maxs[ax])) for ax in ("x", "y")} diff --git a/tests/pl/test_utils.py b/tests/pl/test_utils.py index 87b41495..8617b8dd 100644 --- a/tests/pl/test_utils.py +++ b/tests/pl/test_utils.py @@ -679,3 +679,54 @@ def test_element_none_measures_single_table_elements(self, sdata_blobs: SpatialD # default blobs: only blobs_labels has a single annotating table measure_obs(sdata_blobs) assert "spatial" in sdata_blobs["table"].obsm + + +class TestGetExtentFast: + """`get_extent_fast` matches spatialdata's `get_extent` while skipping the per-geometry transform.""" + + @pytest.mark.parametrize( + ("matrix", "expected"), + [ + ([[2, 0], [0, 3]], True), # anisotropic scale + ([[-1, 0], [0, 1]], True), # flip + ([[0, -1], [1, 0]], True), # 90-degree rotation + ([[0, 1], [1, 0]], True), # axis swap + ([[0.7071, -0.7071], [0.7071, 0.7071]], False), # 45-degree rotation + ([[1, 0.5], [0, 1]], False), # shear + ], + ) + def test_is_axis_aligned(self, matrix, expected): + from spatialdata_plot.pl.utils import _is_axis_aligned + + assert _is_axis_aligned(matrix) is expected + + @pytest.mark.parametrize("element", ["blobs_circles", "blobs_polygons"]) + @pytest.mark.parametrize("kind", ["scale_iso", "scale_aniso", "translate", "flip", "rot90", "rot45", "shear"]) + def test_matches_get_extent(self, sdata_blobs: SpatialData, element: str, kind: str): + import math + + from spatialdata import get_extent + from spatialdata.transformations import Affine, Scale, Translation, set_transformation + + from spatialdata_plot.pl.utils import get_extent_fast + + def _rot(theta: float) -> Affine: + c, s = math.cos(theta), math.sin(theta) + return Affine([[c, -s, 0], [s, c, 0], [0, 0, 1]], input_axes=("x", "y"), output_axes=("x", "y")) + + transforms = { + "scale_iso": Scale([2.0, 2.0], axes=("x", "y")), + "scale_aniso": Scale([2.0, 3.0], axes=("x", "y")), # circles fall back here + "translate": Translation([10.0, 20.0], axes=("x", "y")), + "flip": Scale([-1.0, 1.0], axes=("x", "y")), + "rot90": _rot(math.pi / 2), + "rot45": _rot(math.pi / 4), # not axis-aligned -> fall back + "shear": Affine([[1, 0.5, 0], [0, 1, 0], [0, 0, 1]], input_axes=("x", "y"), output_axes=("x", "y")), + } + set_transformation(sdata_blobs[element], transforms[kind], "cs") + sub = SpatialData(shapes={element: sdata_blobs[element]}) + kw = dict(has_images=False, has_labels=False, has_points=False) + fast = get_extent_fast(sub, "cs", **kw) + exact = get_extent(sub, "cs", exact=True, **kw) + for ax in ("x", "y"): + np.testing.assert_allclose(fast[ax], exact[ax], atol=1e-6) From ae5fcfb57b0067c470ddf17ffb0b98d77cb1c781 Mon Sep 17 00:00:00 2001 From: anon Date: Wed, 10 Jun 2026 13:49:28 +0200 Subject: [PATCH 13/28] refactor: tidy _get_extent_fast (fold helper, total_bounds, batched compute, underscore) Follow-up cleanups from review of the get_extent fast path: - fold _intrinsic_xy_bounds into _element_extent_fast (drop duplicate get_model / geom_type) - geom.bounds -> geom.total_bounds (C-level union, avoids an Nx4 alloc on large collections) - batch the four points min/max into one dask.compute - rename get_extent_fast -> _get_extent_fast (internal helper; matches sibling underscored helpers) No behavior change; output identical for axis-aligned and rotated/sheared elements. --- src/spatialdata_plot/pl/basic.py | 6 ++-- src/spatialdata_plot/pl/utils.py | 55 +++++++++++++------------------- tests/pl/test_utils.py | 6 ++-- 3 files changed, 29 insertions(+), 38 deletions(-) diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index 0de6bed9..ffeb085e 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -64,6 +64,7 @@ _expand_color_panels, _get_cs_contents, _get_elements_to_be_rendered, + _get_extent_fast, _get_valid_cs, _get_wanted_render_elements, _maybe_set_colors, @@ -78,7 +79,6 @@ _validate_shape_render_params, _validate_show_parameters, _verify_plotting_tree, - get_extent_fast, save_fig, ) @@ -1829,10 +1829,10 @@ def _draw_colorbar( "all geometries are empty. Drop the element or restore at least one non-empty geometry." ) - # `get_extent_fast` skips transforming every shapes/points geometry when the element's + # `_get_extent_fast` skips transforming every shapes/points geometry when the element's # transform is axis-aligned (the common scale+translation case); identical result, but # avoids the O(N-geometries) bottleneck for large shape collections. - extent = get_extent_fast( + extent = _get_extent_fast( sdata, coordinate_system=cs, has_images=has_images and wants_images, diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index b65afac4..499b7889 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -4754,28 +4754,6 @@ def _is_axis_aligned(linear2x2: ArrayLike, *, rtol: float = 1e-9) -> bool: return bool((nz.sum(0) <= 1).all() and (nz.sum(1) <= 1).all() and int(nz.sum()) == m.shape[0]) -def _intrinsic_xy_bounds(element: Any) -> tuple[float, float, float, float] | None: - """``(xmin, ymin, xmax, ymax)`` of a shapes/points element in its intrinsic coords, vectorised. - - Circles (``Point`` + ``radius``) expand by the radius; polygons use the vectorised ``.bounds``; - points read the x/y columns. NaN bounds of empty geometries are skipped by min/max, so no - per-geometry empty filter is needed. Returns ``None`` for unsupported element types. - """ - model = get_model(element) - if model is ShapesModel: - geom = element.geometry - if (geom.geom_type == "Point").all(): # circles - x, y = geom.x.to_numpy(), geom.y.to_numpy() - r = np.asarray(element["radius"], dtype=float) - return float(np.nanmin(x - r)), float(np.nanmin(y - r)), float(np.nanmax(x + r)), float(np.nanmax(y + r)) - b = geom.bounds # vectorised; columns minx/miny/maxx/maxy - return float(b["minx"].min()), float(b["miny"].min()), float(b["maxx"].max()), float(b["maxy"].max()) - if model is PointsModel: - x, y = element["x"], element["y"] - return float(x.min().compute()), float(y.min().compute()), float(x.max().compute()), float(y.max().compute()) - return None - - def _element_extent_fast(element: Any, coordinate_system: str) -> dict[str, tuple[float, float]] | None: """Extent of one shapes/points element in ``coordinate_system`` via corner-transform. @@ -4783,7 +4761,8 @@ def _element_extent_fast(element: Any, coordinate_system: str) -> dict[str, tupl unsupported or the transform is not axis-aligned (rotation/shear, where the cheap path would over-estimate). For circles it also falls back under an *anisotropic* linear map: a scaled circle is an ellipse, but spatialdata stores a single uniformly-scaled radius, so the cheap and exact - extents only agree when the scale is isotropic. + extents only agree when the scale is isotropic. Intrinsic bounds are read vectorised (no + per-geometry empty filter); NaN bounds of empty geometries are skipped by the reductions. """ model = get_model(element) if model not in (ShapesModel, PointsModel): @@ -4792,20 +4771,32 @@ def _element_extent_fast(element: Any, coordinate_system: str) -> dict[str, tupl affine = matrix[:2, :2] if not _is_axis_aligned(affine): return None - if model is ShapesModel and bool((element.geometry.geom_type == "Point").all()): # circles - nz = np.abs(affine)[np.abs(affine) > 1e-9 * (np.abs(affine).max() or 1.0)] - if not np.allclose(nz, nz[0]): # anisotropic -> radius handling diverges from spatialdata - return None - bounds = _intrinsic_xy_bounds(element) - if bounds is None: - return None - xmin, ymin, xmax, ymax = bounds + + if model is PointsModel: + x, y = element["x"], element["y"] + xmin, ymin, xmax, ymax = (float(v) for v in dask.compute(x.min(), y.min(), x.max(), y.max())) + else: # ShapesModel + geom = element.geometry + if (geom.geom_type == "Point").all(): # circles + a = np.abs(affine) + nz = a[a > 1e-9 * (a.max() or 1.0)] + if not np.allclose(nz, nz[0]): # anisotropic -> radius handling diverges from spatialdata + return None + x, y = geom.x.to_numpy(), geom.y.to_numpy() + r = np.asarray(element["radius"], dtype=float) + xmin = float(np.nanmin(x - r)) + ymin = float(np.nanmin(y - r)) + xmax = float(np.nanmax(x + r)) + ymax = float(np.nanmax(y + r)) + else: # polygons / multipolygons + xmin, ymin, xmax, ymax = (float(v) for v in geom.total_bounds) # C-level union; skips empties + corners = np.array([[xmin, ymin], [xmax, ymin], [xmin, ymax], [xmax, ymax]]) tc = corners @ affine.T + matrix[:2, 2] return {"x": (float(tc[:, 0].min()), float(tc[:, 0].max())), "y": (float(tc[:, 1].min()), float(tc[:, 1].max()))} -def get_extent_fast( +def _get_extent_fast( sdata: SpatialData, coordinate_system: str, *, diff --git a/tests/pl/test_utils.py b/tests/pl/test_utils.py index 8617b8dd..be8bc0aa 100644 --- a/tests/pl/test_utils.py +++ b/tests/pl/test_utils.py @@ -682,7 +682,7 @@ def test_element_none_measures_single_table_elements(self, sdata_blobs: SpatialD class TestGetExtentFast: - """`get_extent_fast` matches spatialdata's `get_extent` while skipping the per-geometry transform.""" + """`_get_extent_fast` matches spatialdata's `get_extent` while skipping the per-geometry transform.""" @pytest.mark.parametrize( ("matrix", "expected"), @@ -708,7 +708,7 @@ def test_matches_get_extent(self, sdata_blobs: SpatialData, element: str, kind: from spatialdata import get_extent from spatialdata.transformations import Affine, Scale, Translation, set_transformation - from spatialdata_plot.pl.utils import get_extent_fast + from spatialdata_plot.pl.utils import _get_extent_fast def _rot(theta: float) -> Affine: c, s = math.cos(theta), math.sin(theta) @@ -726,7 +726,7 @@ def _rot(theta: float) -> Affine: set_transformation(sdata_blobs[element], transforms[kind], "cs") sub = SpatialData(shapes={element: sdata_blobs[element]}) kw = dict(has_images=False, has_labels=False, has_points=False) - fast = get_extent_fast(sub, "cs", **kw) + fast = _get_extent_fast(sub, "cs", **kw) exact = get_extent(sub, "cs", exact=True, **kw) for ax in ("x", "y"): np.testing.assert_allclose(fast[ax], exact[ax], atol=1e-6) From 4b9891c0ad18a4fabc12aca64be5a4e8138337ef Mon Sep 17 00:00:00 2001 From: anon Date: Wed, 10 Jun 2026 14:30:19 +0200 Subject: [PATCH 14/28] perf(datashader): use _element_extent_fast for the shapes canvas extent The datashader shapes canvas sized itself via spatialdata's exact get_extent, transforming every geometry (O(N)) just for a bounding box -- the same cost _get_extent_fast already removed from show()'s axis limits, in a second code path. Reuse _element_extent_fast (corner transform for axis-aligned elements; None -> exact get_extent fallback for rotation/shear), so the result is pixel-identical and the per-geometry pass is skipped for the common case. Measured on Curio (69,713 colored shapes): render_shapes 3146 ms -> 1675 ms (1.88x), on top of the get_extent_fast win already in show(). Mirrors _datashader_canvas_from_dataframe, which already avoids get_extent for the points path. --- src/spatialdata_plot/pl/utils.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 499b7889..7ab2303c 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -3912,7 +3912,11 @@ def _get_extent_and_range_for_datashader_canvas( coordinate_system: str, fig_params: FigParams, ) -> tuple[Any, Any, list[Any], list[Any], Any]: - extent = get_extent(spatial_element, coordinate_system=coordinate_system) + # The corner-transform fast path avoids transforming every geometry just to size the canvas; + # it is identical for axis-aligned transforms and returns None (-> exact get_extent) otherwise. + extent = _element_extent_fast(spatial_element, coordinate_system) or get_extent( + spatial_element, coordinate_system=coordinate_system + ) x_ext = [float(extent["x"][0]), float(extent["x"][1])] y_ext = [float(extent["y"][0]), float(extent["y"][1])] return _compute_datashader_canvas_params(x_ext, y_ext, fig_params) From ee2513e94a8bae92556cb6530db10a4c3ccbe69d Mon Sep 17 00:00:00 2001 From: anon Date: Wed, 10 Jun 2026 18:50:33 +0200 Subject: [PATCH 15/28] fix(labels): correct as_points no-color rendering + validate size (code review) From the perf-stack review of labels as_points: - No color column collapsed every dot to a single na_color; now each cell gets a distinct random colour, matching the mask path's _map_color_seg Case C. Adds a color assertion to the regression test. - The rasterize drop-filter only ran when a color column was set, so as_points could emit dots at NaN positions (or drop cells) when rasterization removed labels; extend it to as_points so point ids stay within the rendered raster. - ax.scatter autoscales the Normalize in place; copy the shared cmap_params.norm (the shapes path already does). - render_shapes/render_labels(as_points=True) did not validate size; add the points-path numeric/positive check so size=-5 / 'big' raise an actionable error instead of a raw matplotlib failure. --- src/spatialdata_plot/pl/basic.py | 10 ++++++++++ src/spatialdata_plot/pl/render.py | 18 ++++++++++++------ tests/pl/test_render_labels.py | 3 +++ 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index ffeb085e..2377edda 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -451,6 +451,11 @@ def render_shapes( sd.SpatialData A copy of the SpatialData object with the rendering parameters stored in its plotting tree. """ + if as_points: + if isinstance(size, bool) or not isinstance(size, (int, float)): + raise TypeError("Parameter 'size' must be numeric.") + if size <= 0: + raise ValueError("Parameter 'size' must be a positive number.") panel_param_dicts = _expand_color_panels( self._sdata, color, @@ -1051,6 +1056,11 @@ def render_labels( sd.SpatialData A copy of the SpatialData object with the rendering parameters stored in its plotting tree. """ + if as_points: + if isinstance(size, bool) or not isinstance(size, (int, float)): + raise TypeError("Parameter 'size' must be numeric.") + if size <= 0: + raise ValueError("Parameter 'size' must be a positive number.") panel_param_dicts = _expand_color_panels( self._sdata, color, diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 8074cbbc..48a86728 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -2302,9 +2302,9 @@ def _render_labels( len(instance_id), ) - # rasterize could have removed labels from label - # only problematic if color is specified - if rasterize and (col_for_color is not None or col_for_outline_color is not None): + # rasterize/downsampling can drop labels from the raster; remove their (now-absent) instance ids + # so per-instance colors stay aligned and as_points does not emit dots for dropped cells. + if rasterize and (col_for_color is not None or col_for_outline_color is not None or render_params.as_points): mask = np.isin(instance_id, unique_labels) instance_id = instance_id[mask] if col_for_color is not None: @@ -2370,12 +2370,18 @@ def _render_labels( ) # coerce so str/object table ids (e.g. Xenium) match the integer raster labels instead of NaN centroids = centroids.reindex(point_ids.astype(labels.dtype, copy=False)) - # data-driven color is per-instance; literal/no-color is not -> one na color per centroid color_vec = np.asarray(color_vector) - if len(color_vec) == len(instance_id): + if col_for_color is None and not na_color.color_modified_by_user(): + # no color column: one distinct random colour per cell, matching the mask path + # (`_map_color_seg` Case C) instead of collapsing every dot to a single na_color. + point_color_vector = np.random.default_rng(42).random((len(point_ids), 3)) + point_color_source_vector = None + elif len(color_vec) == len(instance_id): + # data-driven colour is per-instance point_color_vector = color_vec[keep] point_color_source_vector = None if color_source_vector is None else color_source_vector[keep] else: + # literal colour / user-set na_color -> one colour per centroid point_color_vector = np.asarray([na_color.get_hex_with_alpha()] * len(point_ids)) point_color_source_vector = None _render_centroids_as_points( @@ -2385,7 +2391,7 @@ def _render_labels( y=centroids["y"].to_numpy(), color_vector=point_color_vector, color_source_vector=point_color_source_vector, - norm=render_params.cmap_params.norm, + norm=copy(render_params.cmap_params.norm), # ax.scatter autoscales in place; don't mutate the shared norm na_color=na_color, transform=trans_data, # rendered-raster intrinsic coords -> coordinate system -> display adata=table if table_name is not None else None, diff --git a/tests/pl/test_render_labels.py b/tests/pl/test_render_labels.py index 6eada3f6..07d5a39d 100644 --- a/tests/pl/test_render_labels.py +++ b/tests/pl/test_render_labels.py @@ -634,6 +634,9 @@ def test_render_labels_as_points_without_color(sdata_blobs: SpatialData): offsets = np.asarray(ax.collections[0].get_offsets()) n_cells = len(sd.get_centroids(sdata_blobs["blobs_labels"], coordinate_system="global").compute()) assert len(offsets) == n_cells # one dot per cell, no spurious background point + # without a color column, cells get distinct random colours (like the mask path), not one na_color + facecolors = ax.collections[0].get_facecolors() + assert len({tuple(np.round(c, 4)) for c in facecolors}) > 1 plt.close(fig) From 158b10870eeb74978f8b9957d3f98c8452a8352c Mon Sep 17 00:00:00 2001 From: anon Date: Wed, 10 Jun 2026 19:01:41 +0200 Subject: [PATCH 16/28] refactor(labels): dedup as_points size validation, lazy color conversion Extract the duplicated as_points size-validation block from render_shapes and render_labels into a shared _validate_as_points_size helper. Defer the np.asarray(color_vector) conversion in the centroid color path to the only branch that uses it. --- src/spatialdata_plot/pl/basic.py | 11 +++-------- src/spatialdata_plot/pl/render.py | 5 ++--- src/spatialdata_plot/pl/utils.py | 8 ++++++++ 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index 2377edda..d9ddc044 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -72,6 +72,7 @@ _prepare_cmap_norm, _prepare_params_plot, _set_outline, + _validate_as_points_size, _validate_graph_render_params, _validate_image_render_params, _validate_label_render_params, @@ -452,10 +453,7 @@ def render_shapes( A copy of the SpatialData object with the rendering parameters stored in its plotting tree. """ if as_points: - if isinstance(size, bool) or not isinstance(size, (int, float)): - raise TypeError("Parameter 'size' must be numeric.") - if size <= 0: - raise ValueError("Parameter 'size' must be a positive number.") + _validate_as_points_size(size) panel_param_dicts = _expand_color_panels( self._sdata, color, @@ -1057,10 +1055,7 @@ def render_labels( A copy of the SpatialData object with the rendering parameters stored in its plotting tree. """ if as_points: - if isinstance(size, bool) or not isinstance(size, (int, float)): - raise TypeError("Parameter 'size' must be numeric.") - if size <= 0: - raise ValueError("Parameter 'size' must be a positive number.") + _validate_as_points_size(size) panel_param_dicts = _expand_color_panels( self._sdata, color, diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 48a86728..c0e8ebc1 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -2370,15 +2370,14 @@ def _render_labels( ) # coerce so str/object table ids (e.g. Xenium) match the integer raster labels instead of NaN centroids = centroids.reindex(point_ids.astype(labels.dtype, copy=False)) - color_vec = np.asarray(color_vector) if col_for_color is None and not na_color.color_modified_by_user(): # no color column: one distinct random colour per cell, matching the mask path # (`_map_color_seg` Case C) instead of collapsing every dot to a single na_color. point_color_vector = np.random.default_rng(42).random((len(point_ids), 3)) point_color_source_vector = None - elif len(color_vec) == len(instance_id): + elif len(color_vector) == len(instance_id): # data-driven colour is per-instance - point_color_vector = color_vec[keep] + point_color_vector = np.asarray(color_vector)[keep] point_color_source_vector = None if color_source_vector is None else color_source_vector[keep] else: # literal colour / user-set na_color -> one colour per centroid diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 7ab2303c..48063c6f 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -3078,6 +3078,14 @@ def _expand_color_panels( return panel_param_dicts +def _validate_as_points_size(size: float) -> None: + """Validate the centroid marker `size` used by ``render_shapes``/``render_labels`` with ``as_points=True``.""" + if isinstance(size, bool) or not isinstance(size, (int, float)): + raise TypeError("Parameter 'size' must be numeric.") + if size <= 0: + raise ValueError("Parameter 'size' must be a positive number.") + + def _validate_label_render_params( sdata: sd.SpatialData, element: str | None, From ae2f1248f914381d5e36966a156cf421fc6e44cf Mon Sep 17 00:00:00 2001 From: anon Date: Wed, 10 Jun 2026 19:13:31 +0200 Subject: [PATCH 17/28] perf(extent): reuse fetched transformations in _element_extent_fast _get_extent_fast already reads get_transformation(element, get_all=True) for the coordinate-system membership check; pass it through so the fast path does not re-fetch it per element. The optional kwarg leaves the datashader-canvas call site unchanged. --- src/spatialdata_plot/pl/utils.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index cb07307e..89f5758c 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -4822,7 +4822,9 @@ def _is_axis_aligned(linear2x2: ArrayLike, *, rtol: float = 1e-9) -> bool: return bool((nz.sum(0) <= 1).all() and (nz.sum(1) <= 1).all() and int(nz.sum()) == m.shape[0]) -def _element_extent_fast(element: Any, coordinate_system: str) -> dict[str, tuple[float, float]] | None: +def _element_extent_fast( + element: Any, coordinate_system: str, *, transformations: Mapping[str, Any] | None = None +) -> dict[str, tuple[float, float]] | None: """Extent of one shapes/points element in ``coordinate_system`` via corner-transform. Returns ``None`` (signalling the caller to fall back to ``get_extent``) when the element type is @@ -4831,11 +4833,16 @@ def _element_extent_fast(element: Any, coordinate_system: str) -> dict[str, tupl is an ellipse, but spatialdata stores a single uniformly-scaled radius, so the cheap and exact extents only agree when the scale is isotropic. Intrinsic bounds are read vectorised (no per-geometry empty filter); NaN bounds of empty geometries are skipped by the reductions. + + ``transformations`` may pass a pre-fetched ``get_transformation(element, get_all=True)`` to avoid + re-reading it when the caller already has it. """ model = get_model(element) if model not in (ShapesModel, PointsModel): return None - matrix = get_transformation(element, get_all=True)[coordinate_system].to_affine_matrix(("x", "y"), ("x", "y")) + if transformations is None: + transformations = get_transformation(element, get_all=True) + matrix = transformations[coordinate_system].to_affine_matrix(("x", "y"), ("x", "y")) affine = matrix[:2, :2] if not _is_axis_aligned(affine): return None @@ -4890,9 +4897,14 @@ def _get_extent_fast( for name, element in edict.items(): if elements is not None and name not in elements: continue - if coordinate_system not in get_transformation(element, get_all=True): + transformations = get_transformation(element, get_all=True) + if coordinate_system not in transformations: continue - ext = _element_extent_fast(element, coordinate_system) if etype in ("shapes", "points") else None + ext = ( + _element_extent_fast(element, coordinate_system, transformations=transformations) + if etype in ("shapes", "points") + else None + ) if ext is None: # rotation/shear, image/label (already cheap), or unsupported ext = get_extent(element, coordinate_system=coordinate_system) for ax in ("x", "y"): From 6b78f38724b0cf135cccb2f2fd710f2a0c68f0ee Mon Sep 17 00:00:00 2001 From: anon Date: Wed, 10 Jun 2026 19:26:14 +0200 Subject: [PATCH 18/28] fix(extent): defer all-empty elements to get_extent; trim comments _element_extent_fast returned a NaN extent for an element whose geometries are all empty, which silently poisoned the union in _get_extent_fast (blank axes) instead of raising spatialdata's clear 'empty collection' error. Guard against non-finite bounds and fall back. Also trims the verbose extent-block comment and the scatter/centroid docstrings (net -12 LOC, no behavior change). --- src/spatialdata_plot/pl/basic.py | 4 +--- src/spatialdata_plot/pl/render.py | 11 ++++------- src/spatialdata_plot/pl/utils.py | 27 ++++++++++----------------- 3 files changed, 15 insertions(+), 27 deletions(-) diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index d9ddc044..2170189b 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -1834,9 +1834,7 @@ def _draw_colorbar( "all geometries are empty. Drop the element or restore at least one non-empty geometry." ) - # `_get_extent_fast` skips transforming every shapes/points geometry when the element's - # transform is axis-aligned (the common scale+translation case); identical result, but - # avoids the O(N-geometries) bottleneck for large shape collections. + # fast path for axis-aligned transforms; identical result, falls back to get_extent otherwise extent = _get_extent_fast( sdata, coordinate_system=cs, diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 56f6bdda..d165f603 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -1092,9 +1092,8 @@ def _scatter_points( ) -> Any: """Draw one marker per (x, y) colored by ``color_vector`` via ``ax.scatter``. - Shared matplotlib scatter primitive: used by ``_render_points`` and (later) by the - centroid-point "fast" rendering of shapes/labels. ``color_vector`` is per-point hex - strings (categorical) or numeric values mapped through ``cmap``/``norm`` (continuous). + Shared scatter primitive for points and the centroid "fast mode" of shapes/labels; + ``color_vector`` is per-point hex strings or numeric values mapped through ``cmap``/``norm``. """ return ax.scatter( x, @@ -1131,10 +1130,8 @@ def _render_centroids_as_points( ) -> None: """Render one dot per cell at ``(x, y)`` colored like the fill, with legend/colorbar. - Shared "fast mode" draw for shapes/labels: cmap/size/alpha/zorder/colorbar come off - ``render_params``; ``color_vector`` is the same per-instance color vector the geometry/raster - path would use, so colors match the full rendering exactly. ``norm``/``na_color`` stay explicit - because they differ between the shapes (locally adjusted) and labels paths. + Shared "fast mode" draw for shapes/labels; style comes off ``render_params``. ``norm``/``na_color`` + stay explicit because they differ between the shapes (locally adjusted) and labels paths. """ cax = _scatter_points( ax, diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 89f5758c..a0230850 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -4800,14 +4800,10 @@ def measure_obs( # --- Fast extent for axis-aligned transforms ------------------------------------------------------ -# `pl.show()` computes axis bounds via spatialdata's `get_extent(..., exact=True)`, which transforms -# EVERY shapes/points geometry into the coordinate system (per-geometry, O(N)) just to take a bounding -# box — the dominant cost when rendering large shape collections. When the element's transform is -# axis-aligned (scale / flip / 90deg rotation / axis swap + translation), the exact extent equals the -# bounding box of the *transformed corners* (spatialdata's own get_extent docstring notes this), so we -# can transform 4 corners instead of N geometries, and read the intrinsic bounds vectorised (avoiding -# spatialdata's per-geometry `.apply(is_empty)` filter). This whole block is self-contained so it can -# be lifted into spatialdata's `get_extent` verbatim; it falls back to `get_extent` for rotation/shear. +# spatialdata's `get_extent(..., exact=True)` transforms every shapes/points geometry (O(N)) just to +# take a bounding box. For an axis-aligned transform (scale/flip/90deg-rotation/axis-swap + translation) +# the exact extent equals the bbox of the *transformed corners*, so we transform 4 corners instead; +# rotation/shear and other element types fall back to `get_extent`. Self-contained for upstreaming. def _is_axis_aligned(linear2x2: ArrayLike, *, rtol: float = 1e-9) -> bool: @@ -4827,15 +4823,10 @@ def _element_extent_fast( ) -> dict[str, tuple[float, float]] | None: """Extent of one shapes/points element in ``coordinate_system`` via corner-transform. - Returns ``None`` (signalling the caller to fall back to ``get_extent``) when the element type is - unsupported or the transform is not axis-aligned (rotation/shear, where the cheap path would - over-estimate). For circles it also falls back under an *anisotropic* linear map: a scaled circle - is an ellipse, but spatialdata stores a single uniformly-scaled radius, so the cheap and exact - extents only agree when the scale is isotropic. Intrinsic bounds are read vectorised (no - per-geometry empty filter); NaN bounds of empty geometries are skipped by the reductions. - - ``transformations`` may pass a pre-fetched ``get_transformation(element, get_all=True)`` to avoid - re-reading it when the caller already has it. + Returns ``None`` to fall back to ``get_extent`` for an unsupported type, a non-axis-aligned + transform, or an anisotropically-scaled circle (an ellipse, but spatialdata stores one radius, so + cheap and exact agree only under isotropic scale). ``transformations`` may pass a pre-fetched + ``get_transformation(element, get_all=True)`` to avoid re-reading it. """ model = get_model(element) if model not in (ShapesModel, PointsModel): @@ -4866,6 +4857,8 @@ def _element_extent_fast( else: # polygons / multipolygons xmin, ymin, xmax, ymax = (float(v) for v in geom.total_bounds) # C-level union; skips empties + if not np.isfinite((xmin, ymin, xmax, ymax)).all(): # all-empty element: defer to get_extent's clear error + return None corners = np.array([[xmin, ymin], [xmax, ymin], [xmin, ymax], [xmax, ymax]]) tc = corners @ affine.T + matrix[:2, 2] return {"x": (float(tc[:, 0].min()), float(tc[:, 0].max())), "y": (float(tc[:, 1].min()), float(tc[:, 1].max()))} From ddb4cc5125b5c5d5f8a45e52f2e8575c4a0f8729 Mon Sep 17 00:00:00 2001 From: anon Date: Wed, 10 Jun 2026 19:38:00 +0200 Subject: [PATCH 19/28] test(as_points): add visual tests for shapes/labels centroid mode + size Four PlotTester baselines: render_shapes/labels(as_points=True) at a base size and a larger size (size = scatter marker area). Baselines to be generated from CI. --- tests/pl/test_render_labels.py | 8 ++++++++ tests/pl/test_render_shapes.py | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/tests/pl/test_render_labels.py b/tests/pl/test_render_labels.py index 07d5a39d..c38f4d5f 100644 --- a/tests/pl/test_render_labels.py +++ b/tests/pl/test_render_labels.py @@ -435,6 +435,14 @@ def test_plot_can_color_labels_by_gene_symbols(self, sdata_blobs: SpatialData): "blobs_labels", color="GeneA", table_name="table", gene_symbols="gene_symbol" ).pl.show() + def test_plot_can_render_labels_as_points(self, sdata_blobs: SpatialData): + """as_points draws one colored dot per label at its centroid instead of the mask.""" + sdata_blobs.pl.render_labels("blobs_labels", color="instance_id", as_points=True, size=100).pl.show() + + def test_plot_labels_as_points_respects_size(self, sdata_blobs: SpatialData): + """size sets the scatter marker area; larger size -> larger dots.""" + sdata_blobs.pl.render_labels("blobs_labels", color="instance_id", as_points=True, size=600).pl.show() + def test_raises_when_table_does_not_annotate_element(sdata_blobs: SpatialData): # Work on an independent copy since we mutate tables diff --git a/tests/pl/test_render_shapes.py b/tests/pl/test_render_shapes.py index 27fc762c..ce502ca2 100644 --- a/tests/pl/test_render_shapes.py +++ b/tests/pl/test_render_shapes.py @@ -1108,6 +1108,14 @@ def test_plot_can_color_shapes_by_gene_symbols(self, sdata_blobs: SpatialData): "blobs_circles", color="GeneA", table_name="table", gene_symbols="gene_symbol" ).pl.show() + def test_plot_can_render_circles_as_points(self, sdata_blobs: SpatialData): + """as_points draws one dot per shape at its centroid instead of the geometry.""" + sdata_blobs.pl.render_shapes("blobs_circles", as_points=True, size=100).pl.show() + + def test_plot_shapes_as_points_respects_size(self, sdata_blobs: SpatialData): + """size sets the scatter marker area; larger size -> larger dots.""" + sdata_blobs.pl.render_shapes("blobs_circles", as_points=True, size=600).pl.show() + def test_gene_symbols_auto_detect_table(sdata_blobs: SpatialData): """gene_symbols resolves correctly without explicit table_name (#247).""" From 1e884953d2ebcad717499975dc0272355674c3a3 Mon Sep 17 00:00:00 2001 From: anon Date: Wed, 10 Jun 2026 20:13:27 +0200 Subject: [PATCH 20/28] test(as_points): add CI-generated baselines for the 4 as_points visual tests Rendered on hatch-test.py3.11-stable; verified dots land at shape/label centroids, colored by instance_id (labels), larger at size=600. --- .../Labels_can_render_labels_as_points.png | Bin 0 -> 32708 bytes .../Labels_labels_as_points_respects_size.png | Bin 0 -> 46243 bytes .../Shapes_can_render_circles_as_points.png | Bin 0 -> 16548 bytes .../Shapes_shapes_as_points_respects_size.png | Bin 0 -> 17584 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/_images/Labels_can_render_labels_as_points.png create mode 100644 tests/_images/Labels_labels_as_points_respects_size.png create mode 100644 tests/_images/Shapes_can_render_circles_as_points.png create mode 100644 tests/_images/Shapes_shapes_as_points_respects_size.png diff --git a/tests/_images/Labels_can_render_labels_as_points.png b/tests/_images/Labels_can_render_labels_as_points.png new file mode 100644 index 0000000000000000000000000000000000000000..405d744a832ea300d9d19e4a9aac90918ae4d084 GIT binary patch literal 32708 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 literal 0 HcmV?d00001 diff --git a/tests/_images/Labels_labels_as_points_respects_size.png b/tests/_images/Labels_labels_as_points_respects_size.png new file mode 100644 index 0000000000000000000000000000000000000000..305c0d95778758ebbbc4f660587b96cf9acd78e8 GIT binary patch literal 46243 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 literal 0 HcmV?d00001 diff --git a/tests/_images/Shapes_can_render_circles_as_points.png b/tests/_images/Shapes_can_render_circles_as_points.png new file mode 100644 index 0000000000000000000000000000000000000000..d6b8d3fbb4e82f74de5cdd7e2c8efd7301b8d743 GIT binary patch literal 16548 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 Lx?G8@Y0&=wS&Jx5 literal 0 HcmV?d00001 diff --git a/tests/_images/Shapes_shapes_as_points_respects_size.png b/tests/_images/Shapes_shapes_as_points_respects_size.png new file mode 100644 index 0000000000000000000000000000000000000000..69451763e04c7857031d6cd11cf97a0b1ae9fc17 GIT binary patch literal 17584 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 literal 0 HcmV?d00001 From 97c5624be30226e9cbc0dc55813c06ff10525d06 Mon Sep 17 00:00:00 2001 From: anon Date: Thu, 11 Jun 2026 00:51:55 +0200 Subject: [PATCH 21/28] perf(shapes): vectorize the all-empty-geometry guard in show() The empty-shape check ran a per-geometry Python lambda (`.apply(lambda g: not g.is_empty)`) over every geometry on every shapes render; GeoSeries.is_empty is GEOS-vectorized. `.is_empty.all()` is identical and ~134x faster on this guard (665ms -> 5ms at 351k shapes, ~10s -> 80ms at 5.5M). --- src/spatialdata_plot/pl/basic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index 2170189b..93e92a91 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -1826,7 +1826,7 @@ def _draw_colorbar( empty_shape_elements = [ name for name in wanted_elements - if name in sdata.shapes and not sdata.shapes[name]["geometry"].apply(lambda g: not g.is_empty).any() + if name in sdata.shapes and sdata.shapes[name]["geometry"].is_empty.all() ] if empty_shape_elements: raise ValueError( From 4c0c478a8e77138a1f69b45c78f63979075669cc Mon Sep 17 00:00:00 2001 From: anon Date: Thu, 11 Jun 2026 03:19:52 +0200 Subject: [PATCH 22/28] refactor(points): extract _datashader_points from _render_points Shared datashader draw primitive (canvas->aggregate->norm->color-key->shade-> image->colorbar), taking explicit primitives instead of render_params so it can also serve the as_points centroid path (shapes/labels params use fill_alpha and lack the density fields). Returns the possibly-recomputed color vectors so the caller's legend/colorbar stays identical. Verified pixel-identical on render_points datashader (categorical/continuous/plain/density). --- src/spatialdata_plot/pl/render.py | 265 ++++++++++++++++-------------- 1 file changed, 143 insertions(+), 122 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index d165f603..9d1463de 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -1163,6 +1163,134 @@ def _render_centroids_as_points( ) +def _datashader_points( + ax: matplotlib.axes.SubplotBase, + df: pd.DataFrame, + *, + col_for_color: str | None, + color_vector: Any, + color_source_vector: Any, + norm: Normalize | None, + cmap_params: CmapParams, + alpha: float, + size: float, + zorder: int, + ds_reduction: _DsReduction | None, + density: bool, + density_how: str, + fig_params: FigParams, + default_reduction: _DsReduction = "sum", +) -> tuple[Any, Any, Any]: + """Datashade an x/y(+color) point frame onto ``ax``; returns ``(cax, color_vector, color_source_vector)``. + + Shared datashader draw for ``render_points`` and the centroid "fast mode" of shapes/labels. ``df`` + holds ``x``/``y`` in coordinate-system coords (+ an optional color column). The (possibly + recomputed) color vectors are returned so the caller's legend/colorbar uses the same values. + Primitives are taken explicitly rather than a ``render_params`` because shapes/labels params use + ``fill_alpha`` and lack the density fields. + """ + # NOTE: s in matplotlib is in units of points**2; use dpi/100 so dpi!=100 still spreads correctly. + # Under density, spreading would smear the count signal across pixels, so disable it. + px: int | None = None if density else int(np.round(np.sqrt(size) * (fig_params.fig.dpi / 100))) + + plot_width, plot_height, x_ext, y_ext, factor = _datashader_canvas_from_dataframe(df, fig_params) + cvs = ds.Canvas(plot_width=plot_width, plot_height=plot_height, x_range=x_ext, y_range=y_ext) + + # ensure color column exists on the frame with positional alignment + if col_for_color is not None and col_for_color not in df.columns: + series_index = df.index + if color_source_vector is not None: + if isinstance(color_source_vector, dd.Series): + color_source_vector = color_source_vector.compute() + source_series = ( + color_source_vector.reindex(series_index) + if isinstance(color_source_vector, pd.Series) + else pd.Series(color_source_vector, index=series_index) + ) + df[col_for_color] = source_series + else: + if isinstance(color_vector, dd.Series): + color_vector = color_vector.compute() + color_series = ( + color_vector.reindex(series_index) + if isinstance(color_vector, pd.Series) + else pd.Series(color_vector, index=series_index) + ) + df[col_for_color] = color_series + + color_dtype = df[col_for_color].dtype if col_for_color is not None else None + color_by_categorical = col_for_color is not None and ( + color_source_vector is not None + or isinstance(color_dtype, pd.CategoricalDtype) + or pd.api.types.is_object_dtype(color_dtype) + or pd.api.types.is_string_dtype(color_dtype) + ) + if color_by_categorical and not isinstance(color_dtype, pd.CategoricalDtype): + df[col_for_color] = df[col_for_color].astype("category") + + agg, reduction_bounds, nan_agg = _ds_aggregate( + cvs, + df, + col_for_color, + color_by_categorical, + ds_reduction, + default_reduction, + "points", + ) + + agg, color_span = _apply_ds_norm(agg, norm) + na_color_hex = _hex_no_alpha(cmap_params.na_color.get_hex()) + if cmap_params.na_color.is_fully_transparent(): + nan_agg = None + color_key = _build_color_key(df, col_for_color, color_by_categorical, color_vector, na_color_hex) + + if ( + color_vector is not None + and len(color_vector) > 0 + and isinstance(color_vector[0], str) + and color_vector[0].startswith("#") + ): + # color_vector usually holds only a few distinct hex strings (one per category), so strip + # alpha on the unique values and map back rather than parsing once per point. + unique_hex, inverse = np.unique(color_vector, return_inverse=True) + color_vector = np.asarray([_hex_no_alpha(c) for c in unique_hex])[inverse] + + shade_how = density_how if density else "linear" + # Plain density (no color column) uses the cmap as a sequential gradient over counts; the + # categorical path collapses to a single color and only modulates alpha. + plain_density = density and col_for_color is None + + nan_shaded = None + if not plain_density and (color_by_categorical or col_for_color is None): + shaded = _ds_shade_categorical( + agg, + color_key, + color_vector, + alpha, + spread_px=px, + how=shade_how, + density=density, + ) + else: + shaded, nan_shaded, reduction_bounds = _ds_shade_continuous( + agg, + color_span, + norm, + cmap_params.cmap, + alpha, + reduction_bounds, + nan_agg, + na_color_hex, + spread_px=px, + ds_reduction=ds_reduction, + how=shade_how, + ) + + _render_ds_image(ax, shaded, factor, zorder, x_min=x_ext[0], y_min=y_ext[0], nan_result=nan_shaded) + cax = _build_ds_colorbar(reduction_bounds, norm, cmap_params.cmap) + return cax, color_vector, color_source_vector + + def _render_points( sdata: sd.SpatialData, render_params: PointsRenderParams, @@ -1373,14 +1501,6 @@ def _render_points( if method == "datashader": _log_datashader_method(method, render_params.ds_reduction, _default_reduction) - # NOTE: s in matplotlib is in units of points**2 - # use dpi/100 as a factor for cases where dpi!=100 - # Under density, spreading would smear the count signal across pixels and - # distort apparent density at sparse edges, so disable it unconditionally. - px: int | None = ( - None if render_params.density else int(np.round(np.sqrt(render_params.size) * (fig_params.fig.dpi / 100))) - ) - # Apply transformations and materialize to pandas immediately so # datashader aggregates without dask scheduler overhead. See #379. transformed_element = PointsModel.parse( @@ -1395,123 +1515,24 @@ def _render_points( # any other elements on the axes. return - plot_width, plot_height, x_ext, y_ext, factor = _datashader_canvas_from_dataframe( - transformed_element, fig_params - ) - - # use datashader for the visualization of points - cvs = ds.Canvas(plot_width=plot_width, plot_height=plot_height, x_range=x_ext, y_range=y_ext) - - # ensure color column exists on the transformed element with positional alignment - if col_for_color is not None and col_for_color not in transformed_element.columns: - series_index = transformed_element.index - if color_source_vector is not None: - if isinstance(color_source_vector, dd.Series): - color_source_vector = color_source_vector.compute() - source_series = ( - color_source_vector.reindex(series_index) - if isinstance(color_source_vector, pd.Series) - else pd.Series(color_source_vector, index=series_index) - ) - transformed_element[col_for_color] = source_series - else: - if isinstance(color_vector, dd.Series): - color_vector = color_vector.compute() - color_series = ( - color_vector.reindex(series_index) - if isinstance(color_vector, pd.Series) - else pd.Series(color_vector, index=series_index) - ) - transformed_element[col_for_color] = color_series - - color_dtype = transformed_element[col_for_color].dtype if col_for_color is not None else None - color_by_categorical = col_for_color is not None and ( - color_source_vector is not None - or isinstance(color_dtype, pd.CategoricalDtype) - or pd.api.types.is_object_dtype(color_dtype) - or pd.api.types.is_string_dtype(color_dtype) - ) - if color_by_categorical and not isinstance(color_dtype, pd.CategoricalDtype): - transformed_element[col_for_color] = transformed_element[col_for_color].astype("category") - - agg, reduction_bounds, nan_agg = _ds_aggregate( - cvs, - transformed_element, - col_for_color, - color_by_categorical, - render_params.ds_reduction, - _default_reduction, - "points", - ) - - agg, color_span = _apply_ds_norm(agg, norm) - na_color_hex = _hex_no_alpha(render_params.cmap_params.na_color.get_hex()) - if render_params.cmap_params.na_color.is_fully_transparent(): - nan_agg = None - color_key = _build_color_key( - transformed_element, - col_for_color, - color_by_categorical, - color_vector, - na_color_hex, - ) - - if ( - color_vector is not None - and len(color_vector) > 0 - and isinstance(color_vector[0], str) - and color_vector[0].startswith("#") - ): - # color_vector usually holds only a few distinct hex strings (one per - # category), so strip alpha on the unique values and map back rather than - # calling the per-string parser once per point. - unique_hex, inverse = np.unique(color_vector, return_inverse=True) - color_vector = np.asarray([_hex_no_alpha(c) for c in unique_hex])[inverse] - - shade_how = render_params.density_how if render_params.density else "linear" - # Plain density (no color column) must use the user-facing cmap as a sequential - # gradient over counts; the categorical path collapses to a single color and only - # modulates alpha, which renders as a flat hue regardless of density. - plain_density = render_params.density and col_for_color is None - - nan_shaded = None - if not plain_density and (color_by_categorical or col_for_color is None): - shaded = _ds_shade_categorical( - agg, - color_key, - color_vector, - render_params.alpha, - spread_px=px, - how=shade_how, - density=render_params.density, - ) - else: - shaded, nan_shaded, reduction_bounds = _ds_shade_continuous( - agg, - color_span, - norm, - render_params.cmap_params.cmap, - render_params.alpha, - reduction_bounds, - nan_agg, - na_color_hex, - spread_px=px, - ds_reduction=render_params.ds_reduction, - how=shade_how, - ) - - _render_ds_image( + cax, color_vector, color_source_vector = _datashader_points( ax, - shaded, - factor, - render_params.zorder, - x_min=x_ext[0], - y_min=y_ext[0], - nan_result=nan_shaded, + transformed_element, + col_for_color=col_for_color, + color_vector=color_vector, + color_source_vector=color_source_vector, + norm=norm, + cmap_params=render_params.cmap_params, + alpha=render_params.alpha, + size=render_params.size, + zorder=render_params.zorder, + ds_reduction=render_params.ds_reduction, + density=render_params.density, + density_how=render_params.density_how, + fig_params=fig_params, + default_reduction=_default_reduction, ) - cax = _build_ds_colorbar(reduction_bounds, norm, render_params.cmap_params.cmap) - elif method == "matplotlib": # update axis limits if plot was empty before (necessary if datashader comes after) update_parameters = not _mpl_ax_contains_elements(ax) From 5854ae6d564a5ef9d25ceae644eb6e8f92ecd852 Mon Sep 17 00:00:00 2001 From: anon Date: Thu, 11 Jun 2026 03:47:44 +0200 Subject: [PATCH 23/28] feat(as_points): datashader backend + fix shapes centroid coordinates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Route as_points centroids through the shared _datashader_points when method= 'datashader' or above ~500k dots (AS_POINTS_DS_AUTO); matplotlib otherwise. - No-color labels (one random colour per cell) cannot be aggregated by datashader; force matplotlib there (warn on explicit method='datashader'). - Fix: shapes as_points drew dots at intrinsic centroid coords while the axes are in coordinate-system coords, so non-identity transforms misplaced them. Transform centroids to CS via the element->CS affine for both backends (labels too). - render_labels gains 'method'; LabelsRenderParams gains 'method'. as_points uses a fixed datashader reduction ('max', closest to matplotlib) — no user knob. --- src/spatialdata_plot/pl/basic.py | 6 ++ src/spatialdata_plot/pl/render.py | 103 ++++++++++++++++++----- src/spatialdata_plot/pl/render_params.py | 2 + 3 files changed, 89 insertions(+), 22 deletions(-) diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index 93e92a91..89b6292c 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -963,6 +963,7 @@ def render_labels( transfunc: Callable[[float], float] | None = None, as_points: bool = False, size: float | int = 1.0, + method: str | None = None, ) -> sd.SpatialData: """ Render labels elements in SpatialData. @@ -1048,6 +1049,10 @@ def render_labels( in another column of ``var``. Mimics scanpy's ``gene_symbols`` parameter. transfunc : Callable[[float], float] | None, optional Optional transformation applied to the continuous color vector before normalization and colormap mapping. + method : str | None, optional + Backend for ``as_points`` centroids: ``'matplotlib'`` or ``'datashader'``. When ``None``, + matplotlib is used unless there are more than ~500k centroids. Datashader is skipped (with a + warning) when the colouring cannot be aggregated (e.g. labels with no color column). Returns ------- @@ -1115,6 +1120,7 @@ def render_labels( colorbar_params=param_values["colorbar_params"], as_points=as_points, size=size, + method=method, panel_key=panel_key, ) n_steps += 1 diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 9d1463de..8ed88ac9 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -757,16 +757,17 @@ def _render_shapes( # Fast mode: draw one dot per shape at its centroid instead of its geometry. logger.info("`as_points=True`: rendering shape centroids; `outline_*` and `shape` are ignored.") centroids = shapes.geometry.centroid # intrinsic coords, positionally aligned to color_vector + # transform to coordinate-system coords so dots land correctly under non-identity transforms + xy = trans.transform(np.column_stack([centroids.x.to_numpy(), centroids.y.to_numpy()])) _render_centroids_as_points( ax, render_params, - x=centroids.x.to_numpy(), - y=centroids.y.to_numpy(), + x=xy[:, 0], + y=xy[:, 1], color_vector=color_vector, color_source_vector=color_source_vector, norm=norm, na_color=render_params.cmap_params.na_color, - transform=trans_data, # intrinsic -> coordinate system -> display adata=table, col_for_color=col_for_color, palette=palette, @@ -1110,6 +1111,34 @@ def _scatter_points( ) +# as_points: matplotlib draws crisp markers; above this many dots its per-glyph draw dominates +# (≈18 µs/dot), so auto-switch to datashader. Datashader changes appearance (density raster), so +# the threshold is conservative and only applies when the user did not pick a backend explicitly. +AS_POINTS_DS_AUTO = 500_000 + + +def _resolve_as_points_method( + render_params: ShapesRenderParams | LabelsRenderParams, *, n: int, allow_datashader: bool +) -> str: + """Pick the as_points backend. matplotlib by default; datashader only when it can represent the colors.""" + method = render_params.method + if not allow_datashader or n == 0: + # e.g. no-color labels get one distinct random colour per cell (`_map_color_seg` Case C), + # which datashader's aggregate-then-shade model cannot represent. + if method == "datashader": + logger.warning("`as_points` cannot use datashader for this colouring; falling back to matplotlib.") + return "matplotlib" + if method == "datashader": + return "datashader" + if method is None and n > AS_POINTS_DS_AUTO: + logger.info( + f"`as_points`: {n} centroids exceed {AS_POINTS_DS_AUTO}; using the datashader backend " + "(pass `method='matplotlib'` to override)." + ) + return "datashader" + return "matplotlib" + + def _render_centroids_as_points( ax: matplotlib.axes.SubplotBase, render_params: ShapesRenderParams | LabelsRenderParams, @@ -1120,31 +1149,56 @@ def _render_centroids_as_points( color_source_vector: pd.Series | None, norm: Normalize | None, na_color: Any, - transform: Any, adata: AnnData | None, col_for_color: str | None, palette: Any, fig_params: FigParams, legend_params: LegendParams, colorbar_requests: list[ColorbarSpec] | None, + allow_datashader: bool = True, ) -> None: """Render one dot per cell at ``(x, y)`` colored like the fill, with legend/colorbar. - Shared "fast mode" draw for shapes/labels; style comes off ``render_params``. ``norm``/``na_color`` - stay explicit because they differ between the shapes (locally adjusted) and labels paths. + Shared "fast mode" draw for shapes/labels. ``x``/``y`` are in **coordinate-system coords** (so the + datashader canvas and matplotlib's ``transData`` agree). Backend is matplotlib unless + ``render_params.method`` / the size threshold selects datashader (and the colouring supports it). + ``norm``/``na_color`` stay explicit because they differ between the shapes and labels paths. """ - cax = _scatter_points( - ax, - x, - y, - color_vector, - size=render_params.size, - cmap=render_params.cmap_params.cmap, - norm=norm, - alpha=render_params.fill_alpha, - trans_data=transform, - zorder=render_params.zorder, - ) + method = _resolve_as_points_method(render_params, n=len(np.asarray(x)), allow_datashader=allow_datashader) + if method == "datashader": + df = pd.DataFrame({"x": np.asarray(x), "y": np.asarray(y)}) + cax, color_vector, color_source_vector = _datashader_points( + ax, + df, + col_for_color=col_for_color, + color_vector=color_vector, + color_source_vector=color_source_vector, + norm=norm, + cmap_params=render_params.cmap_params, + alpha=render_params.fill_alpha, + size=render_params.size, + zorder=render_params.zorder, + # as_points has no user reduction knob: centroids rarely share a pixel, and "max" + # (the top cell) keeps the datashader output closest to the matplotlib backend. + ds_reduction=None, + default_reduction="max", + density=False, + density_how="linear", + fig_params=fig_params, + ) + else: + cax = _scatter_points( + ax, + x, + y, + color_vector, + size=render_params.size, + cmap=render_params.cmap_params.cmap, + norm=norm, + alpha=render_params.fill_alpha, + trans_data=ax.transData, # x/y are coordinate-system coords + zorder=render_params.zorder, + ) _add_legend_and_colorbar( ax=ax, cax=cax, @@ -2266,7 +2320,7 @@ def _render_labels( # get instance id based on subsetted table instance_id = np.unique(table.obs[instance_key].values) - _, trans_data = _prepare_transformation(label, coordinate_system, ax) + trans, trans_data = _prepare_transformation(label, coordinate_system, ax) na_color = ( render_params.color @@ -2387,11 +2441,14 @@ def _render_labels( ) # coerce so str/object table ids (e.g. Xenium) match the integer raster labels instead of NaN centroids = centroids.reindex(point_ids.astype(labels.dtype, copy=False)) + # datashader cannot represent one distinct random colour per cell (the no-color path below) + allow_datashader = True if col_for_color is None and not na_color.color_modified_by_user(): # no color column: one distinct random colour per cell, matching the mask path # (`_map_color_seg` Case C) instead of collapsing every dot to a single na_color. point_color_vector = np.random.default_rng(42).random((len(point_ids), 3)) point_color_source_vector = None + allow_datashader = False elif len(color_vector) == len(instance_id): # data-driven colour is per-instance point_color_vector = np.asarray(color_vector)[keep] @@ -2400,22 +2457,24 @@ def _render_labels( # literal colour / user-set na_color -> one colour per centroid point_color_vector = np.asarray([na_color.get_hex_with_alpha()] * len(point_ids)) point_color_source_vector = None + # transform rendered-raster intrinsic centroids to coordinate-system coords + xy = trans.transform(np.column_stack([centroids["x"].to_numpy(), centroids["y"].to_numpy()])) _render_centroids_as_points( ax, render_params, - x=centroids["x"].to_numpy(), - y=centroids["y"].to_numpy(), + x=xy[:, 0], + y=xy[:, 1], color_vector=point_color_vector, color_source_vector=point_color_source_vector, norm=copy(render_params.cmap_params.norm), # ax.scatter autoscales in place; don't mutate the shared norm na_color=na_color, - transform=trans_data, # rendered-raster intrinsic coords -> coordinate system -> display adata=table if table_name is not None else None, col_for_color=col_for_color, palette=palette, fig_params=fig_params, legend_params=legend_params, colorbar_requests=colorbar_requests, + allow_datashader=allow_datashader, ) return diff --git a/src/spatialdata_plot/pl/render_params.py b/src/spatialdata_plot/pl/render_params.py index 748735b7..28a110b9 100644 --- a/src/spatialdata_plot/pl/render_params.py +++ b/src/spatialdata_plot/pl/render_params.py @@ -334,6 +334,8 @@ class LabelsRenderParams: # Fast mode: render each label as a single dot at its centroid instead of the mask. as_points: bool = False size: float = 1.0 # marker size for as_points (matplotlib scatter ``s``) + # Backend for the as_points centroids: None auto-selects (datashader above ~500k dots). + method: str | None = None # Multi-panel color: when set, this render entry belongs to the panel identified by this # color key. ``None`` means the entry is shared across every panel (e.g. a background layer). panel_key: str | None = None From 77ac531c2141bd14c45abfc7b977cbb2b97d7314 Mon Sep 17 00:00:00 2001 From: anon Date: Thu, 11 Jun 2026 03:53:23 +0200 Subject: [PATCH 24/28] test(as_points): datashader backend + shapes non-identity regression - shapes non-identity transform regression (the coordinate bug fixed in 5854ae6) - backend selection: method='datashader' -> datashader image; default -> matplotlib - no-color labels force matplotlib even with method='datashader' (warns) - _resolve_as_points_method unit test (threshold/explicit/no-color/empty) - two test_plot_* visual tests for datashader as_points (baselines from CI) --- tests/pl/test_render_labels.py | 48 ++++++++++++++++++++++++++++++++++ tests/pl/test_render_shapes.py | 41 +++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/tests/pl/test_render_labels.py b/tests/pl/test_render_labels.py index c38f4d5f..8599d69a 100644 --- a/tests/pl/test_render_labels.py +++ b/tests/pl/test_render_labels.py @@ -443,6 +443,12 @@ def test_plot_labels_as_points_respects_size(self, sdata_blobs: SpatialData): """size sets the scatter marker area; larger size -> larger dots.""" sdata_blobs.pl.render_labels("blobs_labels", color="instance_id", as_points=True, size=600).pl.show() + def test_plot_labels_as_points_datashader(self, sdata_blobs: SpatialData): + """as_points with method='datashader' rasterizes the colored centroids instead of drawing markers.""" + sdata_blobs.pl.render_labels( + "blobs_labels", color="instance_id", as_points=True, method="datashader", size=600 + ).pl.show() + def test_raises_when_table_does_not_annotate_element(sdata_blobs: SpatialData): # Work on an independent copy since we mutate tables @@ -668,3 +674,45 @@ def test_render_labels_as_points_applies_non_identity_transform(sdata_blobs: Spa order_e = np.lexsort((expected_display[:, 1], expected_display[:, 0])) assert np.allclose(dots_display[order_d], expected_display[order_e], atol=3.0) plt.close(fig) + + +def test_render_labels_as_points_method_datashader_renders_image(sdata_blobs: SpatialData): + """method='datashader' under as_points (with a color column) draws a datashaded raster.""" + fig, ax = plt.subplots() + sdata_blobs.pl.render_labels( + "blobs_labels", color="instance_id", as_points=True, method="datashader", size=50 + ).pl.show(ax=ax) + assert len(ax.images) >= 1 + assert len(ax.collections) == 0 + plt.close(fig) + + +def test_render_labels_as_points_no_color_forces_matplotlib(sdata_blobs: SpatialData, caplog): + """No-color labels get one random colour per cell, which datashader cannot represent; even + method='datashader' must fall back to a matplotlib scatter (with a warning).""" + fig, ax = plt.subplots() + with logger_warns(caplog, logger, match="cannot use datashader"): + sdata_blobs.pl.render_labels("blobs_labels", as_points=True, method="datashader").pl.show(ax=ax) + assert len(ax.collections) == 1 # matplotlib scatter, not a datashader image + assert len(ax.images) == 0 + plt.close(fig) + + +def test_resolve_as_points_method_threshold_and_fallback(): + """Backend selection: explicit honored, no-color/empty force matplotlib, auto switches past the cap.""" + from types import SimpleNamespace + + from spatialdata_plot.pl.render import AS_POINTS_DS_AUTO, _resolve_as_points_method + + rp_auto = SimpleNamespace(method=None) + rp_ds = SimpleNamespace(method="datashader") + rp_mpl = SimpleNamespace(method="matplotlib") + # auto: matplotlib below the cap, datashader above + assert _resolve_as_points_method(rp_auto, n=1000, allow_datashader=True) == "matplotlib" + assert _resolve_as_points_method(rp_auto, n=AS_POINTS_DS_AUTO + 1, allow_datashader=True) == "datashader" + # explicit datashader honored only when the colouring allows it + assert _resolve_as_points_method(rp_ds, n=10, allow_datashader=True) == "datashader" + assert _resolve_as_points_method(rp_ds, n=10, allow_datashader=False) == "matplotlib" + # explicit matplotlib always matplotlib; empty always matplotlib + assert _resolve_as_points_method(rp_mpl, n=10**9, allow_datashader=True) == "matplotlib" + assert _resolve_as_points_method(rp_auto, n=0, allow_datashader=True) == "matplotlib" diff --git a/tests/pl/test_render_shapes.py b/tests/pl/test_render_shapes.py index ce502ca2..1c5d100f 100644 --- a/tests/pl/test_render_shapes.py +++ b/tests/pl/test_render_shapes.py @@ -1116,6 +1116,10 @@ def test_plot_shapes_as_points_respects_size(self, sdata_blobs: SpatialData): """size sets the scatter marker area; larger size -> larger dots.""" sdata_blobs.pl.render_shapes("blobs_circles", as_points=True, size=600).pl.show() + def test_plot_shapes_as_points_datashader(self, sdata_blobs: SpatialData): + """as_points with method='datashader' rasterizes the centroids instead of drawing markers.""" + sdata_blobs.pl.render_shapes("blobs_circles", as_points=True, method="datashader", size=600).pl.show() + def test_gene_symbols_auto_detect_table(sdata_blobs: SpatialData): """gene_symbols resolves correctly without explicit table_name (#247).""" @@ -1737,3 +1741,40 @@ def test_render_shapes_as_points_ignores_outline_and_shape(sdata_blobs: SpatialD ).pl.show(ax=ax) assert len(ax.collections) >= 1 # a scatter, not the patch collections of the geometry path plt.close(fig) + + +def test_render_shapes_as_points_applies_non_identity_transform(sdata_blobs: SpatialData): + """Regression: shapes as_points must place dots at coordinate-system positions, not intrinsic ones. + A wrong transform is off by the scale factor (hundreds of px).""" + import spatialdata as sd + from spatialdata.transformations import Scale, set_transformation + + set_transformation(sdata_blobs["blobs_circles"], Scale([3.0, 5.0], axes=("x", "y")), "scaled") + fig, ax = plt.subplots() + sdata_blobs.pl.render_shapes("blobs_circles", as_points=True, size=50).pl.show( + ax=ax, coordinate_systems="scaled" + ) + coll = ax.collections[0] + dots = coll.get_offset_transform().transform(np.asarray(coll.get_offsets())) + cs = sd.get_centroids(sdata_blobs["blobs_circles"], coordinate_system="scaled").compute()[["x", "y"]].to_numpy() + expected = ax.transData.transform(cs) + od, oe = np.lexsort((dots[:, 1], dots[:, 0])), np.lexsort((expected[:, 1], expected[:, 0])) + assert np.allclose(dots[od], expected[oe], atol=3.0) + plt.close(fig) + + +def test_render_shapes_as_points_method_datashader_renders_image(sdata_blobs: SpatialData): + """method='datashader' under as_points draws a datashaded raster, not a scatter collection.""" + fig, ax = plt.subplots() + sdata_blobs.pl.render_shapes("blobs_circles", as_points=True, method="datashader", size=50).pl.show(ax=ax) + assert len(ax.images) >= 1 # datashader image + assert len(ax.collections) == 0 # no matplotlib scatter + plt.close(fig) + + +def test_render_shapes_as_points_default_is_matplotlib(sdata_blobs: SpatialData): + """Small element with method=None uses matplotlib (crisp markers), not datashader.""" + fig, ax = plt.subplots() + sdata_blobs.pl.render_shapes("blobs_circles", as_points=True, size=50).pl.show(ax=ax) + assert len(ax.collections) == 1 and len(ax.images) == 0 + plt.close(fig) From b18bb181ab94e5765cc58e835ebb47d3d5b27adf Mon Sep 17 00:00:00 2001 From: anon Date: Thu, 11 Jun 2026 04:03:01 +0200 Subject: [PATCH 25/28] test(as_points): CI baselines for datashader as_points visual tests Rendered on hatch-test.py3.11-stable; verified centroids datashade at the correct positions (shapes: na_color blobs; labels: colored by instance_id with colorbar). Only these two tests failed on the stable CI envs (missing baselines); no existing baseline regressed from the coordinate fix. --- .../Labels_labels_as_points_datashader.png | Bin 0 -> 39946 bytes .../Shapes_shapes_as_points_datashader.png | Bin 0 -> 17377 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/_images/Labels_labels_as_points_datashader.png create mode 100644 tests/_images/Shapes_shapes_as_points_datashader.png diff --git a/tests/_images/Labels_labels_as_points_datashader.png b/tests/_images/Labels_labels_as_points_datashader.png new file mode 100644 index 0000000000000000000000000000000000000000..96161dd81b66fa6dcacc7d46fd0b0a1a0745d5e7 GIT binary patch literal 39946 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 literal 0 HcmV?d00001 diff --git a/tests/_images/Shapes_shapes_as_points_datashader.png b/tests/_images/Shapes_shapes_as_points_datashader.png new file mode 100644 index 0000000000000000000000000000000000000000..a09677a15a5d71082fdd846521cf47813ccf5eb8 GIT binary patch literal 17377 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 MI32ZDDpukD4`f*YZ2$lO literal 0 HcmV?d00001 From 47ae7bcf452badf41d4263c217b37cefd8a52b43 Mon Sep 17 00:00:00 2001 From: anon Date: Thu, 11 Jun 2026 05:16:50 +0200 Subject: [PATCH 26/28] refactor(as_points): trim comments, drop redundant asarray, fix empty-element warning Code-review cleanup: lean the datashader as_points comments/docstrings, drop np.asarray on already-array x/y, and stop emitting the 'cannot use datashader for this colouring' warning for an empty element (it now warns only for the genuinely unsupported no-color case). Net -5 lines, no behavior change. --- src/spatialdata_plot/pl/render.py | 37 +++++++++++++------------------ 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 8ed88ac9..3e8dea53 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -1111,9 +1111,8 @@ def _scatter_points( ) -# as_points: matplotlib draws crisp markers; above this many dots its per-glyph draw dominates -# (≈18 µs/dot), so auto-switch to datashader. Datashader changes appearance (density raster), so -# the threshold is conservative and only applies when the user did not pick a backend explicitly. +# Above this many centroids matplotlib's per-glyph draw (~18 µs/dot) dominates, so auto-switch to +# datashader. Conservative because datashader changes the look (density raster); only used when method=None. AS_POINTS_DS_AUTO = 500_000 @@ -1123,9 +1122,9 @@ def _resolve_as_points_method( """Pick the as_points backend. matplotlib by default; datashader only when it can represent the colors.""" method = render_params.method if not allow_datashader or n == 0: - # e.g. no-color labels get one distinct random colour per cell (`_map_color_seg` Case C), - # which datashader's aggregate-then-shade model cannot represent. - if method == "datashader": + # no-color labels get one random colour per cell (`_map_color_seg` Case C), which datashader's + # aggregate-then-shade model cannot represent; an empty element just has nothing to draw. + if method == "datashader" and not allow_datashader: logger.warning("`as_points` cannot use datashader for this colouring; falling back to matplotlib.") return "matplotlib" if method == "datashader": @@ -1157,16 +1156,15 @@ def _render_centroids_as_points( colorbar_requests: list[ColorbarSpec] | None, allow_datashader: bool = True, ) -> None: - """Render one dot per cell at ``(x, y)`` colored like the fill, with legend/colorbar. + """Render one dot per cell at ``(x, y)`` (coordinate-system coords), colored like the fill. - Shared "fast mode" draw for shapes/labels. ``x``/``y`` are in **coordinate-system coords** (so the - datashader canvas and matplotlib's ``transData`` agree). Backend is matplotlib unless - ``render_params.method`` / the size threshold selects datashader (and the colouring supports it). - ``norm``/``na_color`` stay explicit because they differ between the shapes and labels paths. + Shared "fast mode" for shapes/labels. Backend is matplotlib unless ``render_params.method`` or the + size threshold selects datashader (and the colouring supports it). ``norm``/``na_color`` are explicit + because they differ between the shapes and labels paths. """ - method = _resolve_as_points_method(render_params, n=len(np.asarray(x)), allow_datashader=allow_datashader) + method = _resolve_as_points_method(render_params, n=len(x), allow_datashader=allow_datashader) if method == "datashader": - df = pd.DataFrame({"x": np.asarray(x), "y": np.asarray(y)}) + df = pd.DataFrame({"x": x, "y": y}) cax, color_vector, color_source_vector = _datashader_points( ax, df, @@ -1235,16 +1233,13 @@ def _datashader_points( fig_params: FigParams, default_reduction: _DsReduction = "sum", ) -> tuple[Any, Any, Any]: - """Datashade an x/y(+color) point frame onto ``ax``; returns ``(cax, color_vector, color_source_vector)``. + """Datashade an x/y(+color) point frame onto ``ax``; return ``(cax, color_vector, color_source_vector)``. - Shared datashader draw for ``render_points`` and the centroid "fast mode" of shapes/labels. ``df`` - holds ``x``/``y`` in coordinate-system coords (+ an optional color column). The (possibly - recomputed) color vectors are returned so the caller's legend/colorbar uses the same values. - Primitives are taken explicitly rather than a ``render_params`` because shapes/labels params use - ``fill_alpha`` and lack the density fields. + Shared by ``render_points`` and the centroid "fast mode" of shapes/labels; ``df`` holds ``x``/``y`` + in coordinate-system coords. The (possibly recomputed) color vectors are returned so the caller's + legend uses the same values. Primitives are explicit because shapes/labels params lack ``alpha``/density. """ - # NOTE: s in matplotlib is in units of points**2; use dpi/100 so dpi!=100 still spreads correctly. - # Under density, spreading would smear the count signal across pixels, so disable it. + # spread radius from marker size (matplotlib points**2, dpi-scaled); off under density to keep counts crisp px: int | None = None if density else int(np.round(np.sqrt(size) * (fig_params.fig.dpi / 100))) plot_width, plot_height, x_ext, y_ext, factor = _datashader_canvas_from_dataframe(df, fig_params) From c50e6103e0ad14db4c9083225fa6fc7a6c8a1ad5 Mon Sep 17 00:00:00 2001 From: anon Date: Thu, 11 Jun 2026 06:32:17 +0200 Subject: [PATCH 27/28] test(as_points): visual test for categorical datashader as_points Covers the categorical-column datashader path (color_source_vector + legend), which the existing no-color (shapes) and continuous (labels/instance_id) tests don't exercise. Baseline to be generated from CI. --- tests/pl/test_render_labels.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/pl/test_render_labels.py b/tests/pl/test_render_labels.py index 8599d69a..a46cf3b3 100644 --- a/tests/pl/test_render_labels.py +++ b/tests/pl/test_render_labels.py @@ -449,6 +449,16 @@ def test_plot_labels_as_points_datashader(self, sdata_blobs: SpatialData): "blobs_labels", color="instance_id", as_points=True, method="datashader", size=600 ).pl.show() + def test_plot_labels_as_points_datashader_categorical(self, sdata_blobs: SpatialData): + """Categorical-coloured as_points centroids datashade with a legend (color_source_vector path).""" + max_col = sdata_blobs["table"].to_df().idxmax(axis=1) + sdata_blobs["table"].obs["which_max"] = pd.Categorical( + max_col, categories=sdata_blobs["table"].to_df().columns, ordered=True + ) + sdata_blobs.pl.render_labels( + "blobs_labels", color="which_max", as_points=True, method="datashader", size=600 + ).pl.show() + def test_raises_when_table_does_not_annotate_element(sdata_blobs: SpatialData): # Work on an independent copy since we mutate tables From 575cf305d0f34374a799530e211f89f07c3e5ec7 Mon Sep 17 00:00:00 2001 From: anon Date: Thu, 11 Jun 2026 06:40:33 +0200 Subject: [PATCH 28/28] test(as_points): CI baseline for categorical datashader as_points Rendered on hatch-test.py3.11-stable; verified datashaded centroids coloured by category with a categorical legend. Only this test failed on stable CI (missing baseline); existing datashader baselines unchanged. --- ..._labels_as_points_datashader_categorical.png | Bin 0 -> 34136 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/_images/Labels_labels_as_points_datashader_categorical.png diff --git a/tests/_images/Labels_labels_as_points_datashader_categorical.png b/tests/_images/Labels_labels_as_points_datashader_categorical.png new file mode 100644 index 0000000000000000000000000000000000000000..bf716cb2bc2bfa1a29e90b601a5beabdadc5070b GIT binary patch literal 34136 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 literal 0 HcmV?d00001