Skip to content

Commit 57bcf8d

Browse files
committed
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.
1 parent b087c1c commit 57bcf8d

3 files changed

Lines changed: 178 additions & 1 deletion

File tree

src/spatialdata_plot/pl/basic.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
_validate_shape_render_params,
7979
_validate_show_parameters,
8080
_verify_plotting_tree,
81+
get_extent_fast,
8182
save_fig,
8283
)
8384

@@ -1828,7 +1829,10 @@ def _draw_colorbar(
18281829
"all geometries are empty. Drop the element or restore at least one non-empty geometry."
18291830
)
18301831

1831-
extent = get_extent(
1832+
# `get_extent_fast` skips transforming every shapes/points geometry when the element's
1833+
# transform is axis-aligned (the common scale+translation case); identical result, but
1834+
# avoids the O(N-geometries) bottleneck for large shape collections.
1835+
extent = get_extent_fast(
18321836
sdata,
18331837
coordinate_system=cs,
18341838
has_images=has_images and wants_images,

src/spatialdata_plot/pl/utils.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
from spatialdata.models import (
7373
Image2DModel,
7474
Labels2DModel,
75+
PointsModel,
7576
ShapesModel,
7677
SpatialElement,
7778
get_model,
@@ -4728,3 +4729,124 @@ def measure_obs(
47284729
table = _resolve_measure_table(target, name, table_name)
47294730
_measure_into_table(target, name, table, centroids=centroids, area=area, diameter=diameter)
47304731
return None if inplace else target
4732+
4733+
4734+
# --- Fast extent for axis-aligned transforms ------------------------------------------------------
4735+
# `pl.show()` computes axis bounds via spatialdata's `get_extent(..., exact=True)`, which transforms
4736+
# EVERY shapes/points geometry into the coordinate system (per-geometry, O(N)) just to take a bounding
4737+
# box — the dominant cost when rendering large shape collections. When the element's transform is
4738+
# axis-aligned (scale / flip / 90deg rotation / axis swap + translation), the exact extent equals the
4739+
# bounding box of the *transformed corners* (spatialdata's own get_extent docstring notes this), so we
4740+
# can transform 4 corners instead of N geometries, and read the intrinsic bounds vectorised (avoiding
4741+
# spatialdata's per-geometry `.apply(is_empty)` filter). This whole block is self-contained so it can
4742+
# be lifted into spatialdata's `get_extent` verbatim; it falls back to `get_extent` for rotation/shear.
4743+
4744+
4745+
def _is_axis_aligned(linear2x2: ArrayLike, *, rtol: float = 1e-9) -> bool:
4746+
"""Whether a 2x2 linear map sends axis-aligned boxes to axis-aligned boxes.
4747+
4748+
True for a *monomial matrix* (at most one non-zero per row and per column): scale, axis flips,
4749+
90/180/270-degree rotations and axis swaps. For such maps the exact extent equals the bounding box
4750+
of the transformed corners. A relative tolerance ignores floating-point noise in the affine matrix.
4751+
"""
4752+
m = np.abs(np.asarray(linear2x2, dtype=float))
4753+
nz = m > rtol * (m.max() or 1.0)
4754+
return bool((nz.sum(0) <= 1).all() and (nz.sum(1) <= 1).all() and int(nz.sum()) == m.shape[0])
4755+
4756+
4757+
def _intrinsic_xy_bounds(element: Any) -> tuple[float, float, float, float] | None:
4758+
"""``(xmin, ymin, xmax, ymax)`` of a shapes/points element in its intrinsic coords, vectorised.
4759+
4760+
Circles (``Point`` + ``radius``) expand by the radius; polygons use the vectorised ``.bounds``;
4761+
points read the x/y columns. NaN bounds of empty geometries are skipped by min/max, so no
4762+
per-geometry empty filter is needed. Returns ``None`` for unsupported element types.
4763+
"""
4764+
model = get_model(element)
4765+
if model is ShapesModel:
4766+
geom = element.geometry
4767+
if (geom.geom_type == "Point").all(): # circles
4768+
x, y = geom.x.to_numpy(), geom.y.to_numpy()
4769+
r = np.asarray(element["radius"], dtype=float)
4770+
return float(np.nanmin(x - r)), float(np.nanmin(y - r)), float(np.nanmax(x + r)), float(np.nanmax(y + r))
4771+
b = geom.bounds # vectorised; columns minx/miny/maxx/maxy
4772+
return float(b["minx"].min()), float(b["miny"].min()), float(b["maxx"].max()), float(b["maxy"].max())
4773+
if model is PointsModel:
4774+
x, y = element["x"], element["y"]
4775+
return float(x.min().compute()), float(y.min().compute()), float(x.max().compute()), float(y.max().compute())
4776+
return None
4777+
4778+
4779+
def _element_extent_fast(element: Any, coordinate_system: str) -> dict[str, tuple[float, float]] | None:
4780+
"""Extent of one shapes/points element in ``coordinate_system`` via corner-transform.
4781+
4782+
Returns ``None`` (signalling the caller to fall back to ``get_extent``) when the element type is
4783+
unsupported or the transform is not axis-aligned (rotation/shear, where the cheap path would
4784+
over-estimate). For circles it also falls back under an *anisotropic* linear map: a scaled circle
4785+
is an ellipse, but spatialdata stores a single uniformly-scaled radius, so the cheap and exact
4786+
extents only agree when the scale is isotropic.
4787+
"""
4788+
model = get_model(element)
4789+
if model not in (ShapesModel, PointsModel):
4790+
return None
4791+
matrix = get_transformation(element, get_all=True)[coordinate_system].to_affine_matrix(("x", "y"), ("x", "y"))
4792+
affine = matrix[:2, :2]
4793+
if not _is_axis_aligned(affine):
4794+
return None
4795+
if model is ShapesModel and bool((element.geometry.geom_type == "Point").all()): # circles
4796+
nz = np.abs(affine)[np.abs(affine) > 1e-9 * (np.abs(affine).max() or 1.0)]
4797+
if not np.allclose(nz, nz[0]): # anisotropic -> radius handling diverges from spatialdata
4798+
return None
4799+
bounds = _intrinsic_xy_bounds(element)
4800+
if bounds is None:
4801+
return None
4802+
xmin, ymin, xmax, ymax = bounds
4803+
corners = np.array([[xmin, ymin], [xmax, ymin], [xmin, ymax], [xmax, ymax]])
4804+
tc = corners @ affine.T + matrix[:2, 2]
4805+
return {"x": (float(tc[:, 0].min()), float(tc[:, 0].max())), "y": (float(tc[:, 1].min()), float(tc[:, 1].max()))}
4806+
4807+
4808+
def get_extent_fast(
4809+
sdata: SpatialData,
4810+
coordinate_system: str,
4811+
*,
4812+
has_images: bool = True,
4813+
has_labels: bool = True,
4814+
has_points: bool = True,
4815+
has_shapes: bool = True,
4816+
elements: list[str] | None = None,
4817+
) -> dict[str, tuple[float, float]]:
4818+
"""Drop-in replacement for spatialdata ``get_extent(sdata, ...)`` with a fast path for shapes/points.
4819+
4820+
Shapes/points with axis-aligned transforms get the corner-transform extent (identical result, no
4821+
per-geometry transform); everything else (rotation/shear, images, labels) delegates to spatialdata's
4822+
``get_extent``. The union semantics match spatialdata's ``get_extent``.
4823+
"""
4824+
include = {"images": has_images, "labels": has_labels, "points": has_points, "shapes": has_shapes}
4825+
element_dicts = {"images": sdata.images, "labels": sdata.labels, "points": sdata.points, "shapes": sdata.shapes}
4826+
mins: dict[str, list[float]] = {"x": [], "y": []}
4827+
maxs: dict[str, list[float]] = {"x": [], "y": []}
4828+
for etype, edict in element_dicts.items():
4829+
if not include[etype]:
4830+
continue
4831+
for name, element in edict.items():
4832+
if elements is not None and name not in elements:
4833+
continue
4834+
if coordinate_system not in get_transformation(element, get_all=True):
4835+
continue
4836+
ext = _element_extent_fast(element, coordinate_system) if etype in ("shapes", "points") else None
4837+
if ext is None: # rotation/shear, image/label (already cheap), or unsupported
4838+
ext = get_extent(element, coordinate_system=coordinate_system)
4839+
for ax in ("x", "y"):
4840+
mins[ax].append(ext[ax][0])
4841+
maxs[ax].append(ext[ax][1])
4842+
if not mins["x"]: # nothing matched -> defer to spatialdata (preserves its error behaviour)
4843+
return get_extent(
4844+
sdata,
4845+
coordinate_system=coordinate_system,
4846+
has_images=has_images,
4847+
has_labels=has_labels,
4848+
has_points=has_points,
4849+
has_shapes=has_shapes,
4850+
elements=elements,
4851+
)
4852+
return {ax: (min(mins[ax]), max(maxs[ax])) for ax in ("x", "y")}

tests/pl/test_utils.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,3 +679,54 @@ def test_element_none_measures_single_table_elements(self, sdata_blobs: SpatialD
679679
# default blobs: only blobs_labels has a single annotating table
680680
measure_obs(sdata_blobs)
681681
assert "spatial" in sdata_blobs["table"].obsm
682+
683+
684+
class TestGetExtentFast:
685+
"""`get_extent_fast` matches spatialdata's `get_extent` while skipping the per-geometry transform."""
686+
687+
@pytest.mark.parametrize(
688+
("matrix", "expected"),
689+
[
690+
([[2, 0], [0, 3]], True), # anisotropic scale
691+
([[-1, 0], [0, 1]], True), # flip
692+
([[0, -1], [1, 0]], True), # 90-degree rotation
693+
([[0, 1], [1, 0]], True), # axis swap
694+
([[0.7071, -0.7071], [0.7071, 0.7071]], False), # 45-degree rotation
695+
([[1, 0.5], [0, 1]], False), # shear
696+
],
697+
)
698+
def test_is_axis_aligned(self, matrix, expected):
699+
from spatialdata_plot.pl.utils import _is_axis_aligned
700+
701+
assert _is_axis_aligned(matrix) is expected
702+
703+
@pytest.mark.parametrize("element", ["blobs_circles", "blobs_polygons"])
704+
@pytest.mark.parametrize("kind", ["scale_iso", "scale_aniso", "translate", "flip", "rot90", "rot45", "shear"])
705+
def test_matches_get_extent(self, sdata_blobs: SpatialData, element: str, kind: str):
706+
import math
707+
708+
from spatialdata import get_extent
709+
from spatialdata.transformations import Affine, Scale, Translation, set_transformation
710+
711+
from spatialdata_plot.pl.utils import get_extent_fast
712+
713+
def _rot(theta: float) -> Affine:
714+
c, s = math.cos(theta), math.sin(theta)
715+
return Affine([[c, -s, 0], [s, c, 0], [0, 0, 1]], input_axes=("x", "y"), output_axes=("x", "y"))
716+
717+
transforms = {
718+
"scale_iso": Scale([2.0, 2.0], axes=("x", "y")),
719+
"scale_aniso": Scale([2.0, 3.0], axes=("x", "y")), # circles fall back here
720+
"translate": Translation([10.0, 20.0], axes=("x", "y")),
721+
"flip": Scale([-1.0, 1.0], axes=("x", "y")),
722+
"rot90": _rot(math.pi / 2),
723+
"rot45": _rot(math.pi / 4), # not axis-aligned -> fall back
724+
"shear": Affine([[1, 0.5, 0], [0, 1, 0], [0, 0, 1]], input_axes=("x", "y"), output_axes=("x", "y")),
725+
}
726+
set_transformation(sdata_blobs[element], transforms[kind], "cs")
727+
sub = SpatialData(shapes={element: sdata_blobs[element]})
728+
kw = dict(has_images=False, has_labels=False, has_points=False)
729+
fast = get_extent_fast(sub, "cs", **kw)
730+
exact = get_extent(sub, "cs", exact=True, **kw)
731+
for ax in ("x", "y"):
732+
np.testing.assert_allclose(fast[ax], exact[ax], atol=1e-6)

0 commit comments

Comments
 (0)