Skip to content

Commit 65a281e

Browse files
committed
2 parents 11953b2 + 2da6c41 commit 65a281e

File tree

10 files changed

+517
-1
lines changed

10 files changed

+517
-1
lines changed

docs/api/operations.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Operations on `SpatialData` objects.
1515
.. autofunction:: match_element_to_table
1616
.. autofunction:: match_table_to_element
1717
.. autofunction:: match_sdata_to_table
18+
.. autofunction:: filter_by_table_query
1819
.. autofunction:: concatenate
1920
.. autofunction:: transform
2021
.. autofunction:: rasterize

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
"datatree": ("https://datatree.readthedocs.io/en/latest/", None),
104104
"dask": ("https://docs.dask.org/en/latest/", None),
105105
"shapely": ("https://shapely.readthedocs.io/en/stable", None),
106+
"annsel": ("https://annsel.readthedocs.io/en/latest/", None),
106107
}
107108

108109

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ license = {file = "LICENSE"}
2323
readme = "README.md"
2424
dependencies = [
2525
"anndata>=0.9.1",
26+
"annsel>=0.1.2",
2627
"click",
2728
"dask-image",
2829
"dask>=2025.2.0",

src/spatialdata/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"match_element_to_table",
2727
"match_table_to_element",
2828
"match_sdata_to_table",
29+
"filter_by_table_query",
2930
"SpatialData",
3031
"get_extent",
3132
"get_centroids",
@@ -57,6 +58,7 @@
5758
from spatialdata._core.operations.vectorize import to_circles, to_polygons
5859
from spatialdata._core.query._utils import get_bounding_box_corners
5960
from spatialdata._core.query.relational_query import (
61+
filter_by_table_query,
6062
get_element_annotators,
6163
get_element_instances,
6264
get_values,

src/spatialdata/_core/operations/transform.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,18 @@ def _(
381381
transformed_dask, raster_translation_single_scale = _transform_raster(
382382
data=xdata.data, axes=xdata.dims, transformation=composed, **kwargs
383383
)
384+
385+
# if a scale in the transformed data has zero shape, we skip it
386+
if 0 in transformed_dask.shape:
387+
if k == "scale0":
388+
raise ValueError(
389+
"The transformation leads to zero shaped data even at the highest resolution level. "
390+
"Check the scaling component of the transformation."
391+
)
392+
# no risk of skipping a scale (e.g. scale1) but not the next ones (e.g. scale2), because once a scale
393+
# is skipped, all the lower scales are also skipped
394+
continue
395+
384396
if raster_translation is None:
385397
raster_translation = raster_translation_single_scale
386398
# we set a dummy empty dict for the transformation that will be replaced with the correct transformation for

src/spatialdata/_core/query/relational_query.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import numpy as np
1111
import pandas as pd
1212
from anndata import AnnData
13+
from annsel.core.typing import Predicates
1314
from dask.dataframe import DataFrame as DaskDataFrame
1415
from geopandas import GeoDataFrame
1516
from xarray import DataArray, DataTree
@@ -650,6 +651,11 @@ def join_spatialelement_table(
650651
ValueError
651652
If an incorrect value is given for `match_rows`.
652653
654+
Notes
655+
-----
656+
For a graphical representation of the join operations, see the
657+
`Tables tutorial <https://spatialdata.scverse.org/en/stable/tutorials/notebooks/notebooks/examples/tables.html>`_.
658+
653659
See Also
654660
--------
655661
match_element_to_table : Function to match elements to a table.
@@ -733,6 +739,11 @@ def match_table_to_element(sdata: SpatialData, element_name: str, table_name: st
733739
-------
734740
Table with the rows matching the instances of the element
735741
742+
Notes
743+
-----
744+
For a graphical representation of the join operations, see the
745+
`Tables tutorial <https://spatialdata.scverse.org/en/stable/tutorials/notebooks/notebooks/examples/tables.html>`_.
746+
736747
See Also
737748
--------
738749
match_element_to_table : Function to match a spatial element to a table.
@@ -763,6 +774,11 @@ def match_element_to_table(
763774
-------
764775
A tuple containing the joined elements as a dictionary and the joined table as an AnnData object.
765776
777+
Notes
778+
-----
779+
For a graphical representation of the join operations, see the
780+
`Tables tutorial <https://spatialdata.scverse.org/en/stable/tutorials/notebooks/notebooks/examples/tables.html>`_.
781+
766782
See Also
767783
--------
768784
match_table_to_element : Function to match a table to a spatial element.
@@ -795,6 +811,10 @@ def match_sdata_to_table(
795811
how
796812
The type of join to perform. See :func:`spatialdata.join_spatialelement_table`. Default is "right".
797813
814+
Notes
815+
-----
816+
For a graphical representation of the join operations, see the
817+
`Tables tutorial <https://spatialdata.scverse.org/en/stable/tutorials/notebooks/notebooks/examples/tables.html>`_.
798818
"""
799819
if table is None:
800820
table = sdata[table_name]
@@ -813,6 +833,73 @@ def match_sdata_to_table(
813833
return SpatialData.init_from_elements(filtered_elements | {table_name: filtered_table})
814834

815835

836+
def filter_by_table_query(
837+
sdata: SpatialData,
838+
table_name: str,
839+
filter_tables: bool = True,
840+
element_names: list[str] | None = None,
841+
obs_expr: Predicates | None = None,
842+
var_expr: Predicates | None = None,
843+
x_expr: Predicates | None = None,
844+
obs_names_expr: Predicates | None = None,
845+
var_names_expr: Predicates | None = None,
846+
layer: str | None = None,
847+
how: Literal["left", "left_exclusive", "inner", "right", "right_exclusive"] = "right",
848+
) -> SpatialData:
849+
"""Filter the SpatialData object based on a set of table queries.
850+
851+
Parameters
852+
----------
853+
sdata
854+
The SpatialData object to filter.
855+
table_name
856+
The name of the table to filter the SpatialData object by.
857+
filter_tables
858+
If True (default), the table is filtered to only contain rows that are annotating regions
859+
contained within the element_names.
860+
element_names
861+
The names of the elements to filter the SpatialData object by.
862+
obs_expr
863+
A Predicate or an iterable of `annsel` `Predicates` to filter :attr:`anndata.AnnData.obs` by.
864+
var_expr
865+
A Predicate or an iterable of `annsel` `Predicates` to filter :attr:`anndata.AnnData.var` by.
866+
x_expr
867+
A Predicate or an iterable of `annsel` `Predicates` to filter :attr:`anndata.AnnData.X` by.
868+
obs_names_expr
869+
A Predicate or an iterable of `annsel` `Predicates` to filter :attr:`anndata.AnnData.obs_names` by.
870+
var_names_expr
871+
A Predicate or an iterable of `annsel` `Predicates` to filter :attr:`anndata.AnnData.var_names` by.
872+
layer
873+
The layer of the :class:`anndata.AnnData` to filter the SpatialData object by, only used with `x_expr`.
874+
how
875+
The type of join to perform. See :func:`spatialdata.join_spatialelement_table`. Default is "right".
876+
877+
Returns
878+
-------
879+
The filtered SpatialData object.
880+
881+
Notes
882+
-----
883+
You can also use :func:`spatialdata.SpatialData.filter_by_table_query` with the convenience that `sdata` is the
884+
current `SpatialData` object.
885+
886+
For a graphical representation of the join operations, see the
887+
`Tables tutorial <https://spatialdata.scverse.org/en/stable/tutorials/notebooks/notebooks/examples/tables.html>`_.
888+
889+
For more examples on table queries, see the
890+
`Table queries tutorial <https://spatialdata.scverse.org/en/stable/tutorials/notebooks/notebooks/examples/table_queries.html>`_.
891+
"""
892+
sdata_subset: SpatialData = (
893+
sdata.subset(element_names=element_names, filter_tables=filter_tables) if element_names else sdata
894+
)
895+
896+
filtered_table: AnnData = sdata_subset.tables[table_name].an.filter(
897+
obs=obs_expr, var=var_expr, x=x_expr, obs_names=obs_names_expr, var_names=var_names_expr, layer=layer
898+
)
899+
900+
return match_sdata_to_table(sdata=sdata_subset, table_name=table_name, table=filtered_table, how=how)
901+
902+
816903
@dataclass
817904
class _ValueOrigin:
818905
origin: str

src/spatialdata/_core/spatialdata.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import pandas as pd
1313
import zarr
1414
from anndata import AnnData
15+
from annsel.core.typing import Predicates
1516
from dask.dataframe import DataFrame as DaskDataFrame
1617
from dask.dataframe import Scalar, read_parquet
1718
from geopandas import GeoDataFrame
@@ -2408,6 +2409,41 @@ def attrs(self, value: Mapping[Any, Any]) -> None:
24082409
else:
24092410
self._attrs = dict(value)
24102411

2412+
def filter_by_table_query(
2413+
self,
2414+
table_name: str,
2415+
filter_tables: bool = True,
2416+
element_names: list[str] | None = None,
2417+
obs_expr: Predicates | None = None,
2418+
var_expr: Predicates | None = None,
2419+
x_expr: Predicates | None = None,
2420+
obs_names_expr: Predicates | None = None,
2421+
var_names_expr: Predicates | None = None,
2422+
layer: str | None = None,
2423+
how: Literal["left", "left_exclusive", "inner", "right", "right_exclusive"] = "right",
2424+
) -> SpatialData:
2425+
"""
2426+
Filter the SpatialData object based on a set of table queries.
2427+
2428+
Please see
2429+
:func:`query.relational_query.filter_by_table_query` for the complete docstring.
2430+
"""
2431+
from spatialdata._core.query.relational_query import filter_by_table_query
2432+
2433+
return filter_by_table_query(
2434+
self,
2435+
table_name=table_name,
2436+
filter_tables=filter_tables,
2437+
element_names=element_names,
2438+
obs_expr=obs_expr,
2439+
var_expr=var_expr,
2440+
x_expr=x_expr,
2441+
obs_names_expr=obs_names_expr,
2442+
var_names_expr=var_names_expr,
2443+
layer=layer,
2444+
how=how,
2445+
)
2446+
24112447

24122448
class QueryManager:
24132449
"""Perform queries on SpatialData objects."""

tests/conftest.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,3 +502,141 @@ def adata_labels() -> AnnData:
502502
"tensor_copy": rng.integers(0, blobs.shape[0], size=(n_obs_labels, 2)),
503503
}
504504
return generate_adata(n_var, obs_labels, obsm_labels, uns_labels)
505+
506+
507+
@pytest.fixture()
508+
def complex_sdata() -> SpatialData:
509+
"""
510+
Create a complex SpatialData object with multiple data types for comprehensive testing.
511+
512+
Contains:
513+
- Images (2D and 3D)
514+
- Labels (2D and 3D)
515+
- Shapes (polygons and circles)
516+
- Points
517+
- Multiple tables with different annotations
518+
- Categorical and numerical values in both obs and var
519+
520+
Returns
521+
-------
522+
SpatialData
523+
A complex SpatialData object for testing.
524+
"""
525+
RNG = np.random.default_rng(seed=SEED)
526+
527+
# Get basic components using existing functions
528+
images = _get_images()
529+
labels = _get_labels()
530+
shapes = _get_shapes()
531+
points = _get_points()
532+
533+
# Create tables with enhanced var data
534+
n_var = 10
535+
536+
# Table 1: Basic table annotating labels2d
537+
obs1 = pd.DataFrame(
538+
{
539+
"region": pd.Categorical(["labels2d"] * 50),
540+
"instance_id": range(1, 51), # Skip background (0)
541+
"cell_type": pd.Categorical(RNG.choice(["T cell", "B cell", "Macrophage"], size=50)),
542+
"size": RNG.uniform(10, 100, size=50),
543+
}
544+
)
545+
546+
var1 = pd.DataFrame(
547+
{
548+
"feature_type": pd.Categorical(["gene", "protein", "gene", "protein", "gene"] * 2),
549+
"importance": RNG.uniform(0, 10, size=n_var),
550+
"is_marker": RNG.choice([True, False], size=n_var),
551+
},
552+
index=[f"feature_{i}" for i in range(n_var)],
553+
)
554+
555+
X1 = RNG.normal(size=(50, n_var))
556+
uns1 = {
557+
"spatialdata_attrs": {
558+
"region": "labels2d",
559+
"region_key": "region",
560+
"instance_key": "instance_id",
561+
}
562+
}
563+
564+
table1 = AnnData(X=X1, obs=obs1, var=var1, uns=uns1)
565+
566+
# Table 2: Annotating both polygons and circles from shapes
567+
n_polygons = len(shapes["poly"])
568+
n_circles = len(shapes["circles"])
569+
total_items = n_polygons + n_circles
570+
571+
obs2 = pd.DataFrame(
572+
{
573+
"region": pd.Categorical(["poly"] * n_polygons + ["circles"] * n_circles),
574+
"instance_id": np.concatenate([range(n_polygons), range(n_circles)]),
575+
"category": pd.Categorical(RNG.choice(["A", "B", "C"], size=total_items)),
576+
"value": RNG.normal(size=total_items),
577+
"count": RNG.poisson(10, size=total_items),
578+
}
579+
)
580+
581+
var2 = pd.DataFrame(
582+
{
583+
"feature_type": pd.Categorical(
584+
["feature_type1", "feature_type2", "feature_type1", "feature_type2", "feature_type1"] * 2
585+
),
586+
"score": RNG.exponential(2, size=n_var),
587+
"detected": RNG.choice([True, False], p=[0.7, 0.3], size=n_var),
588+
},
589+
index=[f"metric_{i}" for i in range(n_var)],
590+
)
591+
592+
X2 = RNG.normal(size=(total_items, n_var))
593+
uns2 = {
594+
"spatialdata_attrs": {
595+
"region": ["poly", "circles"],
596+
"region_key": "region",
597+
"instance_key": "instance_id",
598+
}
599+
}
600+
601+
table2 = AnnData(X=X2, obs=obs2, var=var2, uns=uns2)
602+
603+
# Table 3: Orphan table not annotating any elements
604+
obs3 = pd.DataFrame(
605+
{
606+
"cluster": pd.Categorical(RNG.choice(["cluster_1", "cluster_2", "cluster_3"], size=40)),
607+
"sample": pd.Categorical(["sample_A"] * 20 + ["sample_B"] * 20),
608+
"qc_pass": RNG.choice([True, False], p=[0.8, 0.2], size=40),
609+
}
610+
)
611+
612+
var3 = pd.DataFrame(
613+
{
614+
"feature_type": pd.Categorical(["gene", "protein", "gene", "protein", "gene"] * 2),
615+
"mean_expression": RNG.uniform(0, 20, size=n_var),
616+
"variance": RNG.gamma(2, 2, size=n_var),
617+
},
618+
index=[f"feature_{i}" for i in range(n_var)],
619+
)
620+
621+
X3 = RNG.normal(size=(40, n_var))
622+
table3 = AnnData(X=X3, obs=obs3, var=var3)
623+
624+
# Create additional coordinate system in one of the shapes for testing
625+
# Modified copy of circles with an additional coordinate system
626+
circles_alt_coords = shapes["circles"].copy()
627+
circles_alt_coords["coordinate_system"] = "alt_system"
628+
629+
# Add everything to a SpatialData object
630+
sdata = SpatialData(
631+
images=images,
632+
labels=labels,
633+
shapes={**shapes, "circles_alt_coords": circles_alt_coords},
634+
points=points,
635+
tables={"labels_table": table1, "shapes_table": table2, "orphan_table": table3},
636+
)
637+
638+
# Add layers to tables for testing layer-specific operations
639+
sdata.tables["labels_table"].layers["scaled"] = sdata.tables["labels_table"].X * 2
640+
sdata.tables["labels_table"].layers["log"] = np.log1p(np.abs(sdata.tables["labels_table"].X))
641+
642+
return sdata

0 commit comments

Comments
 (0)