|
72 | 72 | from spatialdata.models import ( |
73 | 73 | Image2DModel, |
74 | 74 | Labels2DModel, |
| 75 | + PointsModel, |
75 | 76 | ShapesModel, |
76 | 77 | SpatialElement, |
77 | 78 | get_model, |
@@ -4728,3 +4729,124 @@ def measure_obs( |
4728 | 4729 | table = _resolve_measure_table(target, name, table_name) |
4729 | 4730 | _measure_into_table(target, name, table, centroids=centroids, area=area, diameter=diameter) |
4730 | 4731 | 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")} |
0 commit comments