From ad74ef34f3eb6ef07c5049a1f20b08e53f6d2c2f Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Mon, 16 Jun 2025 11:27:38 +0200 Subject: [PATCH 01/27] init --- src/squidpy/__init__.py | 2 +- src/squidpy/pp/__init__.py | 6 ++ src/squidpy/pp/_simple.py | 190 +++++++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 src/squidpy/pp/__init__.py create mode 100644 src/squidpy/pp/_simple.py diff --git a/src/squidpy/__init__.py b/src/squidpy/__init__.py index 5fb2b848a..d52a64cbd 100644 --- a/src/squidpy/__init__.py +++ b/src/squidpy/__init__.py @@ -3,7 +3,7 @@ from importlib import metadata from importlib.metadata import PackageMetadata -from squidpy import datasets, gr, im, pl, read, tl +from squidpy import datasets, gr, im, pl, pp, read, tl try: md: PackageMetadata = metadata.metadata(__name__) diff --git a/src/squidpy/pp/__init__.py b/src/squidpy/pp/__init__.py new file mode 100644 index 000000000..cae1751a8 --- /dev/null +++ b/src/squidpy/pp/__init__.py @@ -0,0 +1,6 @@ + +"""Basic pre-processing functions adapted from scanpy.""" + +from __future__ import annotations + +from squidpy.pp._simple import filter_cells \ No newline at end of file diff --git a/src/squidpy/pp/_simple.py b/src/squidpy/pp/_simple.py new file mode 100644 index 000000000..1596ae5b8 --- /dev/null +++ b/src/squidpy/pp/_simple.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import anndata as ad +import numpy as np +import pandas as pd +import scanpy as sc +import spatialdata as sd +import xarray as xr +from spatialdata._logging import logger as logg +from spatialdata.models import Labels2DModel, PointsModel, ShapesModel, get_model +from spatialdata.transformations import get_transformation +from xarray import DataTree + + +def filter_cells( + data: ad.AnnData | sd.SpatialData, + tables: list[str] | str | None = None, + min_counts: int | None = None, + min_genes: int | None = None, + max_counts: int | None = None, + max_genes: int | None = None, + inplace: bool = True, + filter_labels: bool = True, +) -> ad.AnnData | sd.SpatialData | None: + if not isinstance(data, ad.AnnData | sd.SpatialData): + raise ValueError(f"Expected `AnnData` or `SpatialData`, found `{type(data)}`") + + if isinstance(data, ad.AnnData): + if tables is not None: + raise ValueError("When filtering `AnnData`, `tables` is not used.") + return sc.pp.filter_cells( + data, + min_counts=min_counts, + min_genes=min_genes, + max_counts=max_counts, + max_genes=max_genes, + inplace=inplace, + ) + + return _filter_cells_spatialdata(data, tables, min_counts, min_genes, max_counts, max_genes, inplace, filter_labels) + + + +def _filter_cells_spatialdata( + data: sd.SpatialData, + tables: list[str] | str | None = None, + min_counts: int | None = None, + min_genes: int | None = None, + max_counts: int | None = None, + max_genes: int | None = None, + inplace: bool = True, + filter_labels: bool = True, +) -> sd.SpatialData | None: + if isinstance(tables, str): + tables = [tables] + elif tables is None: + tables = list(data.tables.keys()) + + if len(tables) == 0: + raise ValueError("Expected at least one table to be filtered, found `0`") + + if not all(t in data.tables for t in tables): + raise ValueError(f"Expected all tables to be in `{data.tables.keys()}`.") + + for t in tables: + if "spatialdata_attrs" not in data.tables[t].uns: + raise ValueError( + f"Table `{t}` does not have 'spatialdata_attrs' to indicate what it annotates." + ) + + if inplace: + logg.warning( + "Creating a deepcopy of the SpatialData object, depending on the size of the object this can take a while." + ) + data_out = sd.deepcopy(data) + else: + data_out = data + + for t in tables: + table_old = data_out.tables[t] + mask_to_remove, _ = sc.pp.filter_cells(table_old, min_counts=min_counts, min_genes=min_genes, max_counts=max_counts, max_genes=max_genes, inplace=False) + + table_filtered = table_old[~mask_to_remove] + if table_filtered.n_obs == 0 or table_filtered.n_vars == 0: + raise ValueError(f"Filter results in empty table when filtering table `{t}`.") + data_out.tables[t] = table_filtered + + instance_key = data.tables[t].uns["spatialdata_attrs"]["instance_key"] + region_key = data.tables[t].uns["spatialdata_attrs"]["region_key"] + + # region can annotate one (dtype str) or multiple (dtype list[str]) + region = data.tables[t].uns["spatialdata_attrs"]["region"] + if isinstance(region, str): + region = [region] + + removed_obs = table_old.obs[mask_to_remove][[instance_key, region_key]] + + # iterate over all elements that the table annotates (region var) + for r in region: + element_model = get_model(data_out[r]) + + ids_to_remove = removed_obs.query(f"{region_key} == '{r}'")[instance_key].tolist() + if element_model is ShapesModel: + data_out.shapes[r] = _filter_shapesmodel_by_instance_ids( + element=data_out.shapes[r], ids_to_remove=ids_to_remove + ) + + if filter_labels: + logg.warning("Filtering labels, this can be slow depending on the resolution.") + if element_model is Labels2DModel: + new_label = _filter_labels2dmodel_by_instance_ids( + element=data_out.labels[r], ids_to_remove=ids_to_remove + ) + + del data_out.labels[r] + + data_out.labels[r] = new_label + + if inplace: + return None + return data_out + +def _filter_shapesmodel_by_instance_ids(element: ShapesModel, ids_to_remove: list[str]) -> ShapesModel: + return element[~element.index.isin(ids_to_remove)] + + +def _filter_labels2dmodel_by_instance_ids(element: Labels2DModel, ids_to_remove: list[str]) -> Labels2DModel: + def set_ids_in_label_to_zero(image: xr.DataArray, ids_to_remove: list[int]) -> xr.DataArray: + # Use apply_ufunc for efficient processing + def _mask_block(block): + # Create a copy to avoid modifying read-only array + result = block.copy() + result[np.isin(result, ids_to_remove)] = 0 + return result + + processed = xr.apply_ufunc( + _mask_block, + image, + input_core_dims=[["y", "x"]], + output_core_dims=[["y", "x"]], + vectorize=True, + dask="parallelized", + output_dtypes=[image.dtype], + dask_gufunc_kwargs={"allow_rechunk": True}, + ) + + # Force computation to ensure the changes are materialized + computed_result = processed.compute() + + # Create a new DataArray to ensure persistence + result = xr.DataArray( + data=computed_result.data, + coords=image.coords, + dims=image.dims, + attrs=image.attrs.copy(), # Preserve all attributes + ) + + return result + + if isinstance(element, xr.DataArray): + return Labels2DModel.parse(set_ids_in_label_to_zero(element, ids_to_remove)) + + if isinstance(element, DataTree): + # we extract the info to just reconstruct the DataTree after filtering the max scale + max_scale = list(element.keys())[0] + scale_factors = _get_scale_factors(element) + scale_factors = [int(sf[0]) for sf in scale_factors] + + return Labels2DModel.parse( + data=set_ids_in_label_to_zero(element[max_scale].image, ids_to_remove), + scale_factors=scale_factors, + ) + + +def _get_scale_factors(labels_element: Labels2DModel) -> list[tuple[float, float]]: + scales = list(labels_element.keys()) + + # Calculate relative scale factors between consecutive scales + scale_factors = [] + for i in range(len(scales) - 1): + y_size_current = labels_element[scales[i]].image.shape[0] + x_size_current = labels_element[scales[i]].image.shape[1] + y_size_next = labels_element[scales[i + 1]].image.shape[0] + x_size_next = labels_element[scales[i + 1]].image.shape[1] + y_factor = y_size_current / y_size_next + x_factor = x_size_current / x_size_next + + scale_factors.append((y_factor, x_factor)) + + return scale_factors From 866ddc012865a51aae96ec830c7935f1ac1e4b7a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 18 Jun 2025 10:11:41 +0000 Subject: [PATCH 02/27] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/squidpy/pp/__init__.py | 3 +-- src/squidpy/pp/_simple.py | 15 ++++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/squidpy/pp/__init__.py b/src/squidpy/pp/__init__.py index cae1751a8..26edb04a8 100644 --- a/src/squidpy/pp/__init__.py +++ b/src/squidpy/pp/__init__.py @@ -1,6 +1,5 @@ - """Basic pre-processing functions adapted from scanpy.""" from __future__ import annotations -from squidpy.pp._simple import filter_cells \ No newline at end of file +from squidpy.pp._simple import filter_cells diff --git a/src/squidpy/pp/_simple.py b/src/squidpy/pp/_simple.py index 1596ae5b8..ce1415680 100644 --- a/src/squidpy/pp/_simple.py +++ b/src/squidpy/pp/_simple.py @@ -40,7 +40,6 @@ def filter_cells( return _filter_cells_spatialdata(data, tables, min_counts, min_genes, max_counts, max_genes, inplace, filter_labels) - def _filter_cells_spatialdata( data: sd.SpatialData, tables: list[str] | str | None = None, @@ -64,9 +63,7 @@ def _filter_cells_spatialdata( for t in tables: if "spatialdata_attrs" not in data.tables[t].uns: - raise ValueError( - f"Table `{t}` does not have 'spatialdata_attrs' to indicate what it annotates." - ) + raise ValueError(f"Table `{t}` does not have 'spatialdata_attrs' to indicate what it annotates.") if inplace: logg.warning( @@ -78,7 +75,14 @@ def _filter_cells_spatialdata( for t in tables: table_old = data_out.tables[t] - mask_to_remove, _ = sc.pp.filter_cells(table_old, min_counts=min_counts, min_genes=min_genes, max_counts=max_counts, max_genes=max_genes, inplace=False) + mask_to_remove, _ = sc.pp.filter_cells( + table_old, + min_counts=min_counts, + min_genes=min_genes, + max_counts=max_counts, + max_genes=max_genes, + inplace=False, + ) table_filtered = table_old[~mask_to_remove] if table_filtered.n_obs == 0 or table_filtered.n_vars == 0: @@ -120,6 +124,7 @@ def _filter_cells_spatialdata( return None return data_out + def _filter_shapesmodel_by_instance_ids(element: ShapesModel, ids_to_remove: list[str]) -> ShapesModel: return element[~element.index.isin(ids_to_remove)] From d53e20ca99a8df78e2a8c2a21d9ee194098770a6 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Mon, 30 Jun 2025 10:13:31 +0200 Subject: [PATCH 03/27] add tests and fix some bugs --- src/squidpy/pp/_simple.py | 6 +-- tests/preprocessing/test_simple.py | 60 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 tests/preprocessing/test_simple.py diff --git a/src/squidpy/pp/_simple.py b/src/squidpy/pp/_simple.py index ce1415680..77cd4caff 100644 --- a/src/squidpy/pp/_simple.py +++ b/src/squidpy/pp/_simple.py @@ -65,7 +65,7 @@ def _filter_cells_spatialdata( if "spatialdata_attrs" not in data.tables[t].uns: raise ValueError(f"Table `{t}` does not have 'spatialdata_attrs' to indicate what it annotates.") - if inplace: + if not inplace: logg.warning( "Creating a deepcopy of the SpatialData object, depending on the size of the object this can take a while." ) @@ -84,7 +84,7 @@ def _filter_cells_spatialdata( inplace=False, ) - table_filtered = table_old[~mask_to_remove] + table_filtered = table_old[mask_to_remove] if table_filtered.n_obs == 0 or table_filtered.n_vars == 0: raise ValueError(f"Filter results in empty table when filtering table `{t}`.") data_out.tables[t] = table_filtered @@ -97,7 +97,7 @@ def _filter_cells_spatialdata( if isinstance(region, str): region = [region] - removed_obs = table_old.obs[mask_to_remove][[instance_key, region_key]] + removed_obs = table_old.obs[~mask_to_remove][[instance_key, region_key]] # iterate over all elements that the table annotates (region var) for r in region: diff --git a/tests/preprocessing/test_simple.py b/tests/preprocessing/test_simple.py new file mode 100644 index 000000000..cde80d8e3 --- /dev/null +++ b/tests/preprocessing/test_simple.py @@ -0,0 +1,60 @@ +import numpy as np +import anndata as ad +from spatialdata.datasets import blobs_annotating_element +import squidpy as sq +import scanpy as sc +import pytest + + +def _make_sdata(name: str, num_counts: int, count_value: int): + assert num_counts <= 5, "num_counts must be less than 5" + sdata_temp = blobs_annotating_element(name) + m, _ = sdata_temp.tables["table"].shape + n = m + X = np.zeros((m, n)) + # random choice of row + row_indices = np.random.choice(m, num_counts, replace=False) + col_indices = np.random.choice(n, num_counts, replace=False) + X[row_indices, col_indices] = count_value + + sdata_temp.tables["table"] = ad.AnnData( + X=X, + obs={"cell": ["cell" for _ in range(m)], "instance_id": list(range(m)), "region": [name for _ in range(m)]}, + var={"gene": ["gene" for _ in range(n)]}, + uns=sdata_temp.tables["table"].uns, + ) + return sdata_temp + + +@pytest.mark.parametrize("name", ["blobs_labels", "blobs_circles", "blobs_points", "blobs_multiscale_labels"]) +def test_filter_cells(name: str): + filtered_cells = 3 + sdata = _make_sdata(name, num_counts=filtered_cells, count_value=100) + num_cells = sdata.tables["table"].shape[0] + adata_copy = sdata.tables["table"].copy() + sc.pp.filter_cells(adata_copy, max_counts=50, inplace=True) + sq.pp.filter_cells(sdata, max_counts=50, inplace=True) + + assert np.all(sdata.tables["table"].X == adata_copy.X), "Filtered cells are not the same as scanpy" + assert np.all(sdata.tables["table"].obs["cell"] == adata_copy.obs["cell"]), ( + "Filtered cells are not the same as scanpy" + ) + assert np.all(sdata.tables["table"].obs["instance_id"] == adata_copy.obs["instance_id"]), ( + "Filtered cells are not the same as scanpy" + ) + assert sdata.tables["table"].shape[0] == (num_cells - filtered_cells), ( + f"Expected {num_cells - filtered_cells} cells, got {sdata.tables['table'].shape[0]}" + ) + + if name == "blobs_labels": + unique_labels = np.unique(adata_copy.obs["instance_id"]) + unique_labels_sdata = np.unique(sdata.labels["blobs_labels"].data.compute()) + assert np.all(unique_labels == unique_labels_sdata), ( + f"Filtered labels {unique_labels} are not the same as scanpy {unique_labels_sdata}" + ) + + +def test_filter_cells_empty_fail(): + sdata = _make_sdata("blobs_labels", num_counts=5, count_value=200) + with pytest.raises(ValueError, match="Filter results in empty table when filtering table `table`."): + sq.pp.filter_cells(sdata, max_counts=100, inplace=True) From 5fe17bd5af680a6d434a60032a3411dcc34f1561 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Mon, 30 Jun 2025 10:46:50 +0200 Subject: [PATCH 04/27] fix the tests --- src/squidpy/pp/_simple.py | 11 ++++++----- tests/preprocessing/test_simple.py | 18 +++++++++--------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/squidpy/pp/_simple.py b/src/squidpy/pp/_simple.py index 77cd4caff..bdb01a90c 100644 --- a/src/squidpy/pp/_simple.py +++ b/src/squidpy/pp/_simple.py @@ -75,7 +75,7 @@ def _filter_cells_spatialdata( for t in tables: table_old = data_out.tables[t] - mask_to_remove, _ = sc.pp.filter_cells( + mask_filtered, _ = sc.pp.filter_cells( table_old, min_counts=min_counts, min_genes=min_genes, @@ -84,7 +84,7 @@ def _filter_cells_spatialdata( inplace=False, ) - table_filtered = table_old[mask_to_remove] + table_filtered = table_old[mask_filtered] if table_filtered.n_obs == 0 or table_filtered.n_vars == 0: raise ValueError(f"Filter results in empty table when filtering table `{t}`.") data_out.tables[t] = table_filtered @@ -97,7 +97,7 @@ def _filter_cells_spatialdata( if isinstance(region, str): region = [region] - removed_obs = table_old.obs[~mask_to_remove][[instance_key, region_key]] + removed_obs = table_old.obs[~mask_filtered][[instance_key, region_key]] # iterate over all elements that the table annotates (region var) for r in region: @@ -129,10 +129,10 @@ def _filter_shapesmodel_by_instance_ids(element: ShapesModel, ids_to_remove: lis return element[~element.index.isin(ids_to_remove)] -def _filter_labels2dmodel_by_instance_ids(element: Labels2DModel, ids_to_remove: list[str]) -> Labels2DModel: +def _filter_labels2dmodel_by_instance_ids(element: Labels2DModel, ids_to_remove: list[int]) -> Labels2DModel: def set_ids_in_label_to_zero(image: xr.DataArray, ids_to_remove: list[int]) -> xr.DataArray: # Use apply_ufunc for efficient processing - def _mask_block(block): + def _mask_block(block: xr.DataArray) -> xr.DataArray: # Create a copy to avoid modifying read-only array result = block.copy() result[np.isin(result, ids_to_remove)] = 0 @@ -146,6 +146,7 @@ def _mask_block(block): vectorize=True, dask="parallelized", output_dtypes=[image.dtype], + dataset_fill_value=0, dask_gufunc_kwargs={"allow_rechunk": True}, ) diff --git a/tests/preprocessing/test_simple.py b/tests/preprocessing/test_simple.py index cde80d8e3..82daaf9bf 100644 --- a/tests/preprocessing/test_simple.py +++ b/tests/preprocessing/test_simple.py @@ -1,9 +1,12 @@ -import numpy as np +from __future__ import annotations + import anndata as ad +import numpy as np +import pytest +import scanpy as sc from spatialdata.datasets import blobs_annotating_element + import squidpy as sq -import scanpy as sc -import pytest def _make_sdata(name: str, num_counts: int, count_value: int): @@ -19,7 +22,7 @@ def _make_sdata(name: str, num_counts: int, count_value: int): sdata_temp.tables["table"] = ad.AnnData( X=X, - obs={"cell": ["cell" for _ in range(m)], "instance_id": list(range(m)), "region": [name for _ in range(m)]}, + obs=sdata_temp.tables["table"].obs, var={"gene": ["gene" for _ in range(n)]}, uns=sdata_temp.tables["table"].uns, ) @@ -33,12 +36,9 @@ def test_filter_cells(name: str): num_cells = sdata.tables["table"].shape[0] adata_copy = sdata.tables["table"].copy() sc.pp.filter_cells(adata_copy, max_counts=50, inplace=True) - sq.pp.filter_cells(sdata, max_counts=50, inplace=True) + sq.pp.filter_cells(sdata, max_counts=50, inplace=True, filter_labels=True) assert np.all(sdata.tables["table"].X == adata_copy.X), "Filtered cells are not the same as scanpy" - assert np.all(sdata.tables["table"].obs["cell"] == adata_copy.obs["cell"]), ( - "Filtered cells are not the same as scanpy" - ) assert np.all(sdata.tables["table"].obs["instance_id"] == adata_copy.obs["instance_id"]), ( "Filtered cells are not the same as scanpy" ) @@ -49,7 +49,7 @@ def test_filter_cells(name: str): if name == "blobs_labels": unique_labels = np.unique(adata_copy.obs["instance_id"]) unique_labels_sdata = np.unique(sdata.labels["blobs_labels"].data.compute()) - assert np.all(unique_labels == unique_labels_sdata), ( + assert set(unique_labels) == set(unique_labels_sdata).difference([0]), ( f"Filtered labels {unique_labels} are not the same as scanpy {unique_labels_sdata}" ) From 9472b7213b3680f7db7b81873128d99cc1efab9b Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Mon, 30 Jun 2025 14:56:17 +0200 Subject: [PATCH 05/27] relax timeout --- tests/utils/test_parallelize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/test_parallelize.py b/tests/utils/test_parallelize.py index 88922b526..1ba5b4cd3 100644 --- a/tests/utils/test_parallelize.py +++ b/tests/utils/test_parallelize.py @@ -67,7 +67,7 @@ def func(request) -> Callable: # in case of failure. -@pytest.mark.timeout(30) +@pytest.mark.timeout(40) @pytest.mark.parametrize( "backend", [ From c9e48f9bd5b5acc17261c0700666b9a2536b47f0 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Mon, 30 Jun 2025 15:34:36 +0200 Subject: [PATCH 06/27] relax time contraint even more --- tests/utils/test_parallelize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/test_parallelize.py b/tests/utils/test_parallelize.py index 1ba5b4cd3..1b30b63ef 100644 --- a/tests/utils/test_parallelize.py +++ b/tests/utils/test_parallelize.py @@ -67,7 +67,7 @@ def func(request) -> Callable: # in case of failure. -@pytest.mark.timeout(40) +@pytest.mark.timeout(50) @pytest.mark.parametrize( "backend", [ From a9587a2d273f18163f82f6928d46f922e66618d2 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Wed, 9 Jul 2025 13:48:29 +0200 Subject: [PATCH 07/27] docstrings --- docs/api.rst | 12 +++++++++ src/squidpy/pp/_simple.py | 56 ++++++++++++++++++++++++++++----------- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 104d6fe3a..5f934baef 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -57,6 +57,18 @@ Plotting pl.extract pl.var_by_distance +Preprocessing +~~~~~~~~~~~~~ + +.. module:: squidpy.pp +.. currentmodule:: squidpy + +.. autosummary:: + :toctree: api + + pp.filter_cells + + Reading ~~~~~~~ diff --git a/src/squidpy/pp/_simple.py b/src/squidpy/pp/_simple.py index bdb01a90c..14d41d129 100644 --- a/src/squidpy/pp/_simple.py +++ b/src/squidpy/pp/_simple.py @@ -13,7 +13,7 @@ def filter_cells( - data: ad.AnnData | sd.SpatialData, + data: sd.SpatialData, tables: list[str] | str | None = None, min_counts: int | None = None, min_genes: int | None = None, @@ -21,21 +21,45 @@ def filter_cells( max_genes: int | None = None, inplace: bool = True, filter_labels: bool = True, -) -> ad.AnnData | sd.SpatialData | None: - if not isinstance(data, ad.AnnData | sd.SpatialData): - raise ValueError(f"Expected `AnnData` or `SpatialData`, found `{type(data)}`") - - if isinstance(data, ad.AnnData): - if tables is not None: - raise ValueError("When filtering `AnnData`, `tables` is not used.") - return sc.pp.filter_cells( - data, - min_counts=min_counts, - min_genes=min_genes, - max_counts=max_counts, - max_genes=max_genes, - inplace=inplace, - ) +) -> sd.SpatialData: + """\ + Squidpy's implementation of :func:`scanpy.pp.filter_cells` for :class:`anndata.AnnData` and :class:`spatialdata.SpatialData` objects. + For :class:`spatialdata.SpatialData` objects, this function filters the following elements: + + + - labels: filtered based on the values of the images which are assumed to be the instance_id. + - shapes: filtered based on the index which is assumed to be the instance_id. + - points: filtered based on the instance_id column. + + + See :func:`scanpy.pp.filter_cells` for more details regarding the filtering + behavior. + + Parameters + ---------- + data + :class:`spatialdata.SpatialData` object. + tables + If :class:`spatialdata.SpatialData` object, the tables to filter. If `None`, all tables are filtered. + min_counts + Minimum number of counts required for a cell to pass filtering. + min_genes + Minimum number of genes expressed required for a cell to pass filtering. + max_counts + Maximum number of counts required for a cell to pass filtering. + max_genes + Maximum number of genes expressed required for a cell to pass filtering. + inplace + Perform computation inplace or return result. + filter_labels + Whether to filter labels. If `True`, then labels are filtered based on the instance_id column. + + Returns + ------- + If `inplace` then returns the given `data` object after filtering, otherwise returns a copy of the filtered object. + """ + if not isinstance(data, sd.SpatialData): + raise ValueError(f"Expected `SpatialData`, found `{type(data)}` instead. Perhaps you want to use `scanpy.pp.filter_cells` instead.") return _filter_cells_spatialdata(data, tables, min_counts, min_genes, max_counts, max_genes, inplace, filter_labels) From 87e5c8f7d68d2d19289f1fc9eb7c4018c62c3e10 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Wed, 9 Jul 2025 13:56:21 +0200 Subject: [PATCH 08/27] update the filter_cells based on the new spatialdata implementation --- src/squidpy/pp/_simple.py | 54 ++++++++++++--------------------------- 1 file changed, 16 insertions(+), 38 deletions(-) diff --git a/src/squidpy/pp/_simple.py b/src/squidpy/pp/_simple.py index 14d41d129..ebaca5c72 100644 --- a/src/squidpy/pp/_simple.py +++ b/src/squidpy/pp/_simple.py @@ -10,6 +10,7 @@ from spatialdata.models import Labels2DModel, PointsModel, ShapesModel, get_model from spatialdata.transformations import get_transformation from xarray import DataTree +from spatialdata import subset_sdata_by_table_mask def filter_cells( @@ -21,7 +22,7 @@ def filter_cells( max_genes: int | None = None, inplace: bool = True, filter_labels: bool = True, -) -> sd.SpatialData: +) -> sd.SpatialData | None: """\ Squidpy's implementation of :func:`scanpy.pp.filter_cells` for :class:`anndata.AnnData` and :class:`spatialdata.SpatialData` objects. For :class:`spatialdata.SpatialData` objects, this function filters the following elements: @@ -56,10 +57,12 @@ def filter_cells( Returns ------- - If `inplace` then returns the given `data` object after filtering, otherwise returns a copy of the filtered object. + If `inplace` then returns `None`, otherwise returns the filtered :class:`spatialdata.SpatialData` object. """ if not isinstance(data, sd.SpatialData): - raise ValueError(f"Expected `SpatialData`, found `{type(data)}` instead. Perhaps you want to use `scanpy.pp.filter_cells` instead.") + raise ValueError( + f"Expected `SpatialData`, found `{type(data)}` instead. Perhaps you want to use `scanpy.pp.filter_cells` instead." + ) return _filter_cells_spatialdata(data, tables, min_counts, min_genes, max_counts, max_genes, inplace, filter_labels) @@ -107,42 +110,17 @@ def _filter_cells_spatialdata( max_genes=max_genes, inplace=False, ) - - table_filtered = table_old[mask_filtered] - if table_filtered.n_obs == 0 or table_filtered.n_vars == 0: + if mask_filtered.sum() == 0: raise ValueError(f"Filter results in empty table when filtering table `{t}`.") - data_out.tables[t] = table_filtered - - instance_key = data.tables[t].uns["spatialdata_attrs"]["instance_key"] - region_key = data.tables[t].uns["spatialdata_attrs"]["region_key"] - - # region can annotate one (dtype str) or multiple (dtype list[str]) - region = data.tables[t].uns["spatialdata_attrs"]["region"] - if isinstance(region, str): - region = [region] - - removed_obs = table_old.obs[~mask_filtered][[instance_key, region_key]] - - # iterate over all elements that the table annotates (region var) - for r in region: - element_model = get_model(data_out[r]) - - ids_to_remove = removed_obs.query(f"{region_key} == '{r}'")[instance_key].tolist() - if element_model is ShapesModel: - data_out.shapes[r] = _filter_shapesmodel_by_instance_ids( - element=data_out.shapes[r], ids_to_remove=ids_to_remove - ) - - if filter_labels: - logg.warning("Filtering labels, this can be slow depending on the resolution.") - if element_model is Labels2DModel: - new_label = _filter_labels2dmodel_by_instance_ids( - element=data_out.labels[r], ids_to_remove=ids_to_remove - ) - - del data_out.labels[r] - - data_out.labels[r] = new_label + sdata_filtered = subset_sdata_by_table_mask(sdata=data_out, table_name=t, mask=mask_filtered) + data_out.tables[t] = sdata_filtered.tables[t] + for k in list(sdata_filtered.points.keys()): + data_out.points[k] = sdata_filtered.points[k] + for k in list(sdata_filtered.shapes.keys()): + data_out.shapes[k] = sdata_filtered.shapes[k] + if filter_labels: + for k in list(sdata_filtered.labels.keys()): + data_out.labels[k] = sdata_filtered.labels[k] if inplace: return None From 719b69653fbe563f7fba6f78b338b9b5839989f0 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Wed, 9 Jul 2025 14:00:13 +0200 Subject: [PATCH 09/27] correct the docstring --- docs/notebooks | 2 +- src/squidpy/pp/_simple.py | 75 ++------------------------------------- 2 files changed, 3 insertions(+), 74 deletions(-) diff --git a/docs/notebooks b/docs/notebooks index 0b092a258..275ca4e48 160000 --- a/docs/notebooks +++ b/docs/notebooks @@ -1 +1 @@ -Subproject commit 0b092a2580c823e296c30c9321a5a411f9fa91da +Subproject commit 275ca4e482672582d955f4132e51de08a23a798b diff --git a/src/squidpy/pp/_simple.py b/src/squidpy/pp/_simple.py index ebaca5c72..f9b2da72f 100644 --- a/src/squidpy/pp/_simple.py +++ b/src/squidpy/pp/_simple.py @@ -29,8 +29,8 @@ def filter_cells( - labels: filtered based on the values of the images which are assumed to be the instance_id. - - shapes: filtered based on the index which is assumed to be the instance_id. - - points: filtered based on the instance_id column. + - points: filtered based on the index which is assumed to be the instance_id. + - shapes: filtered based on the instance_id column. See :func:`scanpy.pp.filter_cells` for more details regarding the filtering @@ -125,74 +125,3 @@ def _filter_cells_spatialdata( if inplace: return None return data_out - - -def _filter_shapesmodel_by_instance_ids(element: ShapesModel, ids_to_remove: list[str]) -> ShapesModel: - return element[~element.index.isin(ids_to_remove)] - - -def _filter_labels2dmodel_by_instance_ids(element: Labels2DModel, ids_to_remove: list[int]) -> Labels2DModel: - def set_ids_in_label_to_zero(image: xr.DataArray, ids_to_remove: list[int]) -> xr.DataArray: - # Use apply_ufunc for efficient processing - def _mask_block(block: xr.DataArray) -> xr.DataArray: - # Create a copy to avoid modifying read-only array - result = block.copy() - result[np.isin(result, ids_to_remove)] = 0 - return result - - processed = xr.apply_ufunc( - _mask_block, - image, - input_core_dims=[["y", "x"]], - output_core_dims=[["y", "x"]], - vectorize=True, - dask="parallelized", - output_dtypes=[image.dtype], - dataset_fill_value=0, - dask_gufunc_kwargs={"allow_rechunk": True}, - ) - - # Force computation to ensure the changes are materialized - computed_result = processed.compute() - - # Create a new DataArray to ensure persistence - result = xr.DataArray( - data=computed_result.data, - coords=image.coords, - dims=image.dims, - attrs=image.attrs.copy(), # Preserve all attributes - ) - - return result - - if isinstance(element, xr.DataArray): - return Labels2DModel.parse(set_ids_in_label_to_zero(element, ids_to_remove)) - - if isinstance(element, DataTree): - # we extract the info to just reconstruct the DataTree after filtering the max scale - max_scale = list(element.keys())[0] - scale_factors = _get_scale_factors(element) - scale_factors = [int(sf[0]) for sf in scale_factors] - - return Labels2DModel.parse( - data=set_ids_in_label_to_zero(element[max_scale].image, ids_to_remove), - scale_factors=scale_factors, - ) - - -def _get_scale_factors(labels_element: Labels2DModel) -> list[tuple[float, float]]: - scales = list(labels_element.keys()) - - # Calculate relative scale factors between consecutive scales - scale_factors = [] - for i in range(len(scales) - 1): - y_size_current = labels_element[scales[i]].image.shape[0] - x_size_current = labels_element[scales[i]].image.shape[1] - y_size_next = labels_element[scales[i + 1]].image.shape[0] - x_size_next = labels_element[scales[i + 1]].image.shape[1] - y_factor = y_size_current / y_size_next - x_factor = x_size_current / x_size_next - - scale_factors.append((y_factor, x_factor)) - - return scale_factors From 484d849f83b909f203387668c5a486b3069dfe19 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 9 Jul 2025 12:00:03 +0000 Subject: [PATCH 10/27] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/squidpy/pp/_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/squidpy/pp/_simple.py b/src/squidpy/pp/_simple.py index f9b2da72f..06381bc94 100644 --- a/src/squidpy/pp/_simple.py +++ b/src/squidpy/pp/_simple.py @@ -6,11 +6,11 @@ import scanpy as sc import spatialdata as sd import xarray as xr +from spatialdata import subset_sdata_by_table_mask from spatialdata._logging import logger as logg from spatialdata.models import Labels2DModel, PointsModel, ShapesModel, get_model from spatialdata.transformations import get_transformation from xarray import DataTree -from spatialdata import subset_sdata_by_table_mask def filter_cells( From 94cceaf0b996138cc672df10c0d0ff5119953c19 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Wed, 20 Aug 2025 16:31:22 +0200 Subject: [PATCH 11/27] push the current state --- docs/notebooks | 2 +- src/squidpy/pp/_simple.py | 44 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/docs/notebooks b/docs/notebooks index 275ca4e48..296295a16 160000 --- a/docs/notebooks +++ b/docs/notebooks @@ -1 +1 @@ -Subproject commit 275ca4e482672582d955f4132e51de08a23a798b +Subproject commit 296295a1682ad0f06757fe532a631803fad05c87 diff --git a/src/squidpy/pp/_simple.py b/src/squidpy/pp/_simple.py index 06381bc94..770b640a8 100644 --- a/src/squidpy/pp/_simple.py +++ b/src/squidpy/pp/_simple.py @@ -11,6 +11,12 @@ from spatialdata.models import Labels2DModel, PointsModel, ShapesModel, get_model from spatialdata.transformations import get_transformation from xarray import DataTree +from spatialdata import SpatialData +from spatialdata.models import get_table_keys +from spatialdata.models import points_dask_dataframe_to_geopandas, points_geopandas_to_dask_dataframe + +from dask.dataframe import DataFrame as DaskDataFrame +import geopandas as gpd def filter_cells( @@ -67,6 +73,37 @@ def filter_cells( return _filter_cells_spatialdata(data, tables, min_counts, min_genes, max_counts, max_genes, inplace, filter_labels) +def _get_only_annotated_shape(sdata: sd.SpatialData, table_name: str) -> str | None: + table = sdata.tables[table_name] + + # only one shape needs to be annotated to filter points within it + # other annotations can't be points + + regions, _, _ = get_table_keys(table) + if len(regions) == 0: + return None + + if isinstance(regions, str): + regions = [regions] + + res = None + for r in regions: + if r in sdata.points: + return None + if r in sdata.shapes: + if res is not None: + return None + res = r + + return res + + +def _filter_points_within_shape_geopandas(points_df: DaskDataFrame, shape_df: gpd.GeoDataFrame) -> DaskDataFrame: + points_gdf = points_dask_dataframe_to_geopandas(points_df) + res = points_gdf.sjoin(shape_df, how="left", predicate="within") + return points_geopandas_to_dask_dataframe(res) + + def _filter_cells_spatialdata( data: sd.SpatialData, tables: list[str] | str | None = None, @@ -121,7 +158,12 @@ def _filter_cells_spatialdata( if filter_labels: for k in list(sdata_filtered.labels.keys()): data_out.labels[k] = sdata_filtered.labels[k] - + shape_name = _get_only_annotated_shape(data_out, t) + if shape_name is not None: + for p in data_out.points: + data_out.points[p] = _filter_points_within_shape_geopandas( + data_out.points[p], data_out.shapes[shape_name] + ) if inplace: return None return data_out From 85612d4e991bb4bb843339f7ea121c2d747fc224 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Thu, 21 Aug 2025 12:49:59 +0200 Subject: [PATCH 12/27] update filter_cells function --- src/squidpy/pp/_simple.py | 64 ++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/src/squidpy/pp/_simple.py b/src/squidpy/pp/_simple.py index 770b640a8..90f1d2b09 100644 --- a/src/squidpy/pp/_simple.py +++ b/src/squidpy/pp/_simple.py @@ -1,22 +1,17 @@ from __future__ import annotations -import anndata as ad +import geopandas as gpd import numpy as np -import pandas as pd import scanpy as sc import spatialdata as sd -import xarray as xr -from spatialdata import subset_sdata_by_table_mask -from spatialdata._logging import logger as logg -from spatialdata.models import Labels2DModel, PointsModel, ShapesModel, get_model -from spatialdata.transformations import get_transformation -from xarray import DataTree -from spatialdata import SpatialData -from spatialdata.models import get_table_keys -from spatialdata.models import points_dask_dataframe_to_geopandas, points_geopandas_to_dask_dataframe - from dask.dataframe import DataFrame as DaskDataFrame -import geopandas as gpd +from spatialdata import SpatialData, subset_sdata_by_table_mask +from spatialdata._logging import logger as logg +from spatialdata.models import ( + get_table_keys, + points_dask_dataframe_to_geopandas, + points_geopandas_to_dask_dataframe, +) def filter_cells( @@ -98,9 +93,30 @@ def _get_only_annotated_shape(sdata: sd.SpatialData, table_name: str) -> str | N return res -def _filter_points_within_shape_geopandas(points_df: DaskDataFrame, shape_df: gpd.GeoDataFrame) -> DaskDataFrame: - points_gdf = points_dask_dataframe_to_geopandas(points_df) - res = points_gdf.sjoin(shape_df, how="left", predicate="within") +def _annotated_points_by_shape_membership( + sdata: SpatialData, + point_key: str, + shape_key: str, +) -> DaskDataFrame: + """Annotate points by shape membership. + + Parameters + ---------- + sdata + The SpatialData object to annotate. + point_key + The key of the points to annotate. + shape_key + The key of the shapes to annotate. + + Returns + ------- + The annotated points. + """ + points = sdata.points[point_key] + shapes = sdata.shapes[shape_key] + points_gdf = points_dask_dataframe_to_geopandas(points) + res = points_gdf.sjoin(shapes, how="left", predicate="within") return points_geopandas_to_dask_dataframe(res) @@ -161,9 +177,21 @@ def _filter_cells_spatialdata( shape_name = _get_only_annotated_shape(data_out, t) if shape_name is not None: for p in data_out.points: - data_out.points[p] = _filter_points_within_shape_geopandas( - data_out.points[p], data_out.shapes[shape_name] + _, _, instance_key = get_table_keys(table_old) + shape_index_name = data_out.shapes[shape_name].index.name + new_points = _annotated_points_by_shape_membership( + sdata=data_out, + shape_key=shape_name, + point_key=p, ) + shape_index_name += "_right" + removed_instance_ids = list(np.unique(table_old.obs[instance_key][~mask_filtered])) + # drop points that are not in any shape + new_points = new_points.dropna() + # drop points that are in the removed_instance_ids + new_points = new_points[~new_points[shape_index_name].isin(removed_instance_ids)] + data_out.points[p] = new_points + if inplace: return None return data_out From 267e29702b42f22da4c0e0d02e297256a6dd5340 Mon Sep 17 00:00:00 2001 From: Wenjie Sun Date: Wed, 6 Aug 2025 15:39:11 +0200 Subject: [PATCH 13/27] Re-implementating co_occurrence() (#975) * perf implement rust co-occurrence statistics * misc: change rust-py deps * doc: improve the documentation * add python re-implementation * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Clean the tests and dependencies * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Optimize memory access pattern & cache kernel * jit the outer function and parallelize * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix Mypy checking Typing error * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Disable the cast typing in jit * Try fix typing check error by mypy * Try: fix typing check error by mypy * Try: fix typing check error by mypy * removed copy cleanup the code * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * remove unused "type ignore" * chore: trigger CI check --------- Co-authored-by: Daniele Lucarelli <108922919+MDLDan@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Tim Treis Co-authored-by: selmanozleyen --- src/squidpy/gr/_ppatterns.py | 198 ++++++++++++++--------------------- src/squidpy/im/_io.py | 6 +- 2 files changed, 82 insertions(+), 122 deletions(-) diff --git a/src/squidpy/gr/_ppatterns.py b/src/squidpy/gr/_ppatterns.py index 187f1156a..0286a63f2 100644 --- a/src/squidpy/gr/_ppatterns.py +++ b/src/squidpy/gr/_ppatterns.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Iterable, Sequence +from importlib.util import find_spec from itertools import chain from typing import TYPE_CHECKING, Any, Literal @@ -10,7 +11,7 @@ import numpy as np import pandas as pd from anndata import AnnData -from numba import njit +from numba import njit, prange from numpy.random import default_rng from scanpy import logging as logg from scanpy.get import _get_obs_rep @@ -266,85 +267,82 @@ def _score_helper( return score_perms -@njit( - ft[:, :, :](tt(it[:], 2), ft[:, :], it[:], ft[:], bl), - parallel=False, - fastmath=True, -) +@njit(parallel=True, fastmath=True, cache=True) def _occur_count( - clust: tuple[NDArrayA, NDArrayA], - pw_dist: NDArrayA, - labs_unique: NDArrayA, - interval: NDArrayA, - same_split: bool, + spatial_x: NDArrayA, spatial_y: NDArrayA, thresholds: NDArrayA, label_idx: NDArrayA, n: int, k: int, l_val: int ) -> NDArrayA: - num = labs_unique.shape[0] - out = np.zeros((num, num, interval.shape[0] - 1), dtype=ft) - - for idx in range(interval.shape[0] - 1): - co_occur = np.zeros((num, num), dtype=ft) - probs_con = np.zeros((num, num), dtype=ft) - - thres_max = interval[idx + 1] - clust_x, clust_y = clust - - # Modified to compute co-occurrence probability ratio over increasing radii sizes as opposed to discrete interval bins - # Need pw_dist > 0 to avoid counting a cell with itself as co-occurrence - idx_x, idx_y = np.nonzero((pw_dist <= thres_max) & (pw_dist > 0)) - x = clust_x[idx_x] - y = clust_y[idx_y] - # Treat computing co-occurrence using the same split and different splits differently - # Pairwise distance matrix for between the same split is symmetric and therefore only needs to be counted once - for i, j in zip(x, y): # noqa: B905 # cannot use strict=False because of numba - co_occur[i, j] += 1 - if not same_split: - co_occur[j, i] += 1 - - # Prevent divison by zero errors when we have low cell counts/small intervals - probs_matrix = co_occur / np.sum(co_occur) if np.sum(co_occur) != 0 else np.zeros((num, num), dtype=ft) - probs = np.sum(probs_matrix, axis=0) - - for c in labs_unique: - probs_conditional = ( - co_occur[c] / np.sum(co_occur[c]) if np.sum(co_occur[c]) != 0 else np.zeros(num, dtype=ft) - ) - probs_con[c, :] = np.zeros(num, dtype=ft) - for i in range(num): - if probs[i] == 0: - probs_con[c, i] = 0 - else: - probs_con[c, i] = probs_conditional[i] / probs[i] - - out[:, :, idx] = probs_con - - return out - - -def _co_occurrence_helper( - idx_splits: Iterable[tuple[int, int]], - spatial_splits: Sequence[NDArrayA], - labs_splits: Sequence[NDArrayA], - labs_unique: NDArrayA, - interval: NDArrayA, - queue: SigQueue | None = None, -) -> pd.DataFrame: - out_lst = [] - for t in idx_splits: - idx_x, idx_y = t - labs_x = labs_splits[idx_x] - labs_y = labs_splits[idx_y] - dist = pairwise_distances(spatial_splits[idx_x], spatial_splits[idx_y]) + # Allocate a 2D array to store a flat local result per point. + k2 = k * k + local_results = np.zeros((n, l_val * k2), dtype=np.int32) - out = _occur_count((labs_x, labs_y), dist, labs_unique, interval, idx_x == idx_y) - out_lst.append(out) + for i in prange(n): + for j in range(n): + if i == j: + continue + dx = spatial_x[i] - spatial_x[j] + dy = spatial_y[i] - spatial_y[j] + d2 = dx * dx + dy * dy - if queue is not None: - queue.put(Signal.UPDATE) + pair = label_idx[i] * k + label_idx[j] # fixed in r–loop + base = pair * l_val # first cell for that pair - if queue is not None: - queue.put(Signal.FINISH) + for r in range(l_val): + if d2 <= thresholds[r]: + local_results[i][base + r] += 1 - return out_lst + # reduction and reshape stay the same + result_flat = local_results.sum(axis=0) + result: NDArrayA = result_flat.reshape(k, k, l_val) + + return result + + +@njit(parallel=True, fastmath=True, cache=True) +def _co_occurrence_helper(v_x: NDArrayA, v_y: NDArrayA, v_radium: NDArrayA, labs: NDArrayA) -> NDArrayA: + """ + Fast co-occurrence probability computation using the new numba-accelerated counting. + + Parameters + ---------- + v_x : np.ndarray, float64 + x–coordinates. + v_y : np.ndarray, float64 + y–coordinates. + v_radium : np.ndarray, float64 + Distance thresholds (in ascending order). + labs : np.ndarray + Cluster labels (as integers). + + Returns + ------- + occ_prob : np.ndarray + A 3D array of shape (k, k, len(v_radium)-1) containing the co-occurrence probabilities. + labs_unique : np.ndarray + Array of unique labels. + """ + n = len(v_x) + labs_unique = np.unique(labs) + k = len(labs_unique) + # l_val is the number of bins; here we assume the thresholds come from v_radium[1:]. + l_val = len(v_radium) - 1 + # Compute squared thresholds from the interval (skip the first value) + thresholds = (v_radium[1:]) ** 2 + + # Compute co-occurence counts. + counts = _occur_count(v_x, v_y, thresholds, labs, n, k, l_val) + + occ_prob = np.zeros((k, k, l_val), dtype=np.float64) + row_sums = counts.sum(axis=0) + totals = row_sums.sum(axis=0) + + for r in prange(l_val): + probs = row_sums[:, r] / totals[r] + for c in range(k): + for i in range(k): + if probs[i] != 0.0 and row_sums[c, r] != 0.0: + occ_prob[i, c, r] = (counts[c, i, r] / row_sums[c, r]) / probs[i] + + return occ_prob @d.dedent @@ -387,6 +385,7 @@ def co_occurrence( - :attr:`anndata.AnnData.uns` ``['{cluster_key}_co_occurrence']['interval']`` - the distance thresholds computed at ``interval``. """ + if isinstance(adata, SpatialData): adata = adata.table _assert_categorical_obs(adata, key=cluster_key) @@ -394,11 +393,8 @@ def co_occurrence( spatial = adata.obsm[spatial_key].astype(fp) original_clust = adata.obs[cluster_key] - - # annotate cluster idx clust_map = {v: i for i, v in enumerate(original_clust.cat.categories.values)} labs = np.array([clust_map[c] for c in original_clust], dtype=ip) - labs_unique = np.array(list(clust_map.values()), dtype=ip) # create intervals thresholds if isinstance(interval, int): @@ -409,57 +405,21 @@ def co_occurrence( if len(interval) <= 1: raise ValueError(f"Expected interval to be of length `>= 2`, found `{len(interval)}`.") - n_obs = spatial.shape[0] - if n_splits is None: - size_arr = (n_obs**2 * spatial.itemsize) / 1024 / 1024 # calc expected mem usage - n_splits = 1 - if size_arr > 2000: - while (n_obs / n_splits) > 2048: - n_splits += 1 - logg.warning( - f"`n_splits` was automatically set to `{n_splits}` to " - f"prevent `{n_obs}x{n_obs}` distance matrix from being created" - ) - n_splits = int(max(min(n_splits, n_obs), 1)) - - # split array and labels - spatial_splits = tuple(s for s in np.array_split(spatial, n_splits, axis=0) if len(s)) - labs_splits = tuple(s for s in np.array_split(labs, n_splits, axis=0) if len(s)) - # create idx array including unique combinations and self-comparison - x, y = np.triu_indices_from(np.empty((n_splits, n_splits))) - idx_splits = list(zip(x, y, strict=False)) + spatial_x = spatial[:, 0] + spatial_y = spatial[:, 1] - n_jobs = _get_n_cores(n_jobs) + # Compute co-occurrence probabilities using the fast numba routine. + out = _co_occurrence_helper(spatial_x, spatial_y, interval, labs) start = logg.info( - f"Calculating co-occurrence probabilities for `{len(interval)}` intervals " - f"`{len(idx_splits)}` split combinations using `{n_jobs}` core(s)" - ) - - out_lst = parallelize( - _co_occurrence_helper, - collection=idx_splits, - extractor=chain.from_iterable, - n_jobs=n_jobs, - backend=backend, - show_progress_bar=show_progress_bar, - )( - spatial_splits=spatial_splits, - labs_splits=labs_splits, - labs_unique=labs_unique, - interval=interval, + f"Calculating co-occurrence probabilities for `{len(interval)}` intervals using `{n_jobs}` core(s) and `{n_splits}` splits" ) - out = list(out_lst)[0] if len(idx_splits) == 1 else sum(list(out_lst)) / len(idx_splits) if copy: logg.info("Finish", time=start) return out, interval _save_data( - adata, - attr="uns", - key=Key.uns.co_occurrence(cluster_key), - data={"occ": out, "interval": interval}, - time=start, + adata, attr="uns", key=Key.uns.co_occurrence(cluster_key), data={"occ": out, "interval": interval}, time=start ) diff --git a/src/squidpy/im/_io.py b/src/squidpy/im/_io.py index 80a330bb6..3f092470b 100644 --- a/src/squidpy/im/_io.py +++ b/src/squidpy/im/_io.py @@ -25,7 +25,7 @@ def _assert_dims_present(dims: tuple[str, ...], include_z: bool = True) -> None: # modification of `skimage`'s `pil_to_ndarray`: # https://github.com/scikit-image/scikit-image/blob/main/skimage/io/_plugins/pil_plugin.py#L55 -def _infer_shape_dtype(fname: str) -> tuple[tuple[int, ...], np.dtype]: # type: ignore[type-arg] +def _infer_shape_dtype(fname: str) -> tuple[tuple[int, ...], np.dtype]: def _palette_is_grayscale(pil_image: Image.Image) -> bool: # get palette as an array with R, G, B columns palette = np.asarray(pil_image.getpalette()).reshape((256, 3)) @@ -81,7 +81,7 @@ def _palette_is_grayscale(pil_image: Image.Image) -> bool: raise ValueError(f"Unable to infer image dtype for image mode `{image.mode}`.") -def _get_image_shape_dtype(fname: str) -> tuple[tuple[int, ...], np.dtype]: # type: ignore[type-arg] +def _get_image_shape_dtype(fname: str) -> tuple[tuple[int, ...], np.dtype]: try: return _infer_shape_dtype(fname) except Image.UnidentifiedImageError as e: @@ -101,7 +101,7 @@ def _get_image_shape_dtype(fname: str) -> tuple[tuple[int, ...], np.dtype]: # t def _infer_dimensions( obj: NDArrayA | xr.DataArray | str, infer_dimensions: InferDimensions | tuple[str, ...] = InferDimensions.DEFAULT, -) -> tuple[tuple[int, ...], tuple[str, ...], np.dtype, tuple[int, ...]]: # type: ignore[type-arg] +) -> tuple[tuple[int, ...], tuple[str, ...], np.dtype, tuple[int, ...]]: """ Infer dimension names of an array. From 385b70c8202e0e9c1a966bf5324cea17037cd2e3 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Thu, 14 Aug 2025 14:09:17 +0200 Subject: [PATCH 14/27] chore: fix messed-up yaml formatting (#1024) --- .editorconfig | 3 + .github/.codecov.yml | 22 ++-- .github/release.yml | 54 ++++----- .github/workflows/build.yml | 44 ++++---- .github/workflows/deployment.yml | 110 +++++++++--------- .github/workflows/release.yml | 52 ++++----- .github/workflows/test.yml | 186 +++++++++++++++---------------- .pre-commit-config.yaml | 42 +++---- .readthedocs.yml | 30 ++--- pyproject.toml | 5 +- 10 files changed, 275 insertions(+), 273 deletions(-) diff --git a/.editorconfig b/.editorconfig index 2fe0ce085..0cd3945fb 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,3 +10,6 @@ insert_final_newline = true [Makefile] indent_style = tab + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.github/.codecov.yml b/.github/.codecov.yml index 872442c76..04c00f56e 100644 --- a/.github/.codecov.yml +++ b/.github/.codecov.yml @@ -1,17 +1,17 @@ # Based on pydata/xarray codecov: - require_ci_to_pass: false + require_ci_to_pass: false coverage: - status: - project: - default: - # Require 1% coverage, i.e., always succeed - target: 1 - patch: false - changes: false + status: + project: + default: + # Require 1% coverage, i.e., always succeed + target: 1 + patch: false + changes: false comment: - layout: "diff, flags, files" - behavior: once - require_base: false + layout: "diff, flags, files" + behavior: once + require_base: false diff --git a/.github/release.yml b/.github/release.yml index 9601ba6f8..a5de5a36f 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -1,28 +1,28 @@ changelog: - exclude: - labels: - - release-ignore - authors: - - pre-commit-ci - categories: - - title: Added - labels: - - "release-added" - - title: Changed - labels: - - "release-changed" - - title: Deprecated - labels: - - "release-deprecated" - - title: Removed - labels: - - "release-removed" - - title: Fixed - labels: - - "release-fixed" - - title: Security - labels: - - "release-security" - - title: Other Changes - labels: - - "*" + exclude: + labels: + - release-ignore + authors: + - pre-commit-ci + categories: + - title: Added + labels: + - "release-added" + - title: Changed + labels: + - "release-changed" + - title: Deprecated + labels: + - "release-deprecated" + - title: Removed + labels: + - "release-removed" + - title: Fixed + labels: + - "release-fixed" + - title: Security + labels: + - "release-security" + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bff6d13f8..8df623435 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,29 +1,29 @@ name: build on: - push: - branches: [main] - pull_request: - branches: [main] + push: + branches: [main] + pull_request: + branches: [main] concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - package: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.10 - uses: actions/setup-python@v4 - with: - python-version: "3.10" - cache: "pip" - cache-dependency-path: "**/pyproject.toml" - - name: Install build dependencies - run: python -m pip install --upgrade pip wheel twine build - - name: Build package - run: python -m build - - name: Check package - run: twine check --strict dist/*.whl + package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v4 + with: + python-version: "3.10" + cache: "pip" + cache-dependency-path: "**/pyproject.toml" + - name: Install build dependencies + run: python -m pip install --upgrade pip wheel twine build + - name: Build package + run: python -m build + - name: Check package + run: twine check --strict dist/*.whl diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index d5b0da082..3c1ddac98 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -1,60 +1,60 @@ name: Deployment on: - push: - branches: [main] - tags: [v*] - workflow_dispatch: - inputs: - reason: - description: Reason for the workflow dispatch. Only "release" is valid. - required: true - default: release + push: + branches: [main] + tags: [v*] + workflow_dispatch: + inputs: + reason: + description: Reason for the workflow dispatch. Only "release" is valid. + required: true + default: release jobs: - deploy: - if: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.reason == 'release') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags')) }} - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Set up Python 3.10 - uses: actions/setup-python@v4 - with: - python-version: "3.10" - - - name: Install hatch - run: pip install hatch - - # this will fail if the last commit is not tagged - - name: Build project for distribution - run: hatch build - - - name: Publish on PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: __token__ - password: ${{ secrets.PYPI_TOKEN }} - skip_existing: true - verbose: true - - sync-branches: - if: ${{ github.event_name == 'workflow_dispatch' }} - needs: deploy - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Extract branch name - id: vars - run: | - echo ::set-output name=branch::${GITHUB_REF#refs/*/} - - - name: Merge release into main - uses: everlytic/branch-merge@1.1.2 - with: - github_token: ${{ secrets.RELEASE_DISPATCH_TOKEN }} - target_branch: main - commit_message_template: ${{ format('[auto][ci skip] Merge branch ''{0}'' into main', steps.vars.outputs.branch) }} + deploy: + if: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.reason == 'release') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags')) }} + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Set up Python 3.10 + uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - name: Install hatch + run: pip install hatch + + # this will fail if the last commit is not tagged + - name: Build project for distribution + run: hatch build + + - name: Publish on PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_TOKEN }} + skip-existing: true + verbose: true + + sync-branches: + if: ${{ github.event_name == 'workflow_dispatch' }} + needs: deploy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Extract branch name + id: vars + run: | + echo ::set-output name=branch::${GITHUB_REF#refs/*/} + + - name: Merge release into main + uses: everlytic/branch-merge@1.1.2 + with: + github_token: ${{ secrets.RELEASE_DISPATCH_TOKEN }} + target_branch: main + commit_message_template: ${{ format('[auto][ci skip] Merge branch ''{0}'' into main', steps.vars.outputs.branch) }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e96d72081..295bb3081 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,31 +1,31 @@ name: Release on: - release: - types: [published] + release: + types: [published] jobs: - package_and_release: - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.12 - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: pip - - name: Install build dependencies - run: python -m pip install --upgrade pip wheel twine build - - name: Build package - run: python -m build - - name: Check package - run: twine check --strict dist/*.whl - - name: Install hatch - run: pip install hatch - - name: Build project for distribution - run: hatch build - - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_TOKEN }} + package_and_release: + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install build dependencies + run: python -m pip install --upgrade pip wheel twine build + - name: Build package + run: python -m build + - name: Check package + run: twine check --strict dist/*.whl + - name: Install hatch + run: pip install hatch + - name: Build project for distribution + run: hatch build + - name: Publish a Python distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9baeaf72f..2e277e200 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,110 +1,110 @@ name: Test on: - schedule: - - cron: 00 00 * * 1 # every Monday at 00:00 - push: - branches: [main] - pull_request: - branches: [main] - workflow_dispatch: - inputs: - reason: - description: Reason for the workflow dispatch. Only "release" is valid. - required: true - default: release + schedule: + - cron: 00 00 * * 1 # every Monday at 00:00 + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + inputs: + reason: + description: Reason for the workflow dispatch. Only "release" is valid. + required: true + default: release jobs: - test: - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - python: ["3.10", "3.11", "3.12"] - os: [ubuntu-latest] - include: - - python: "3.12" - os: macos-latest - pip-flags: "--pre" - name: "Python 3.12 (pre-release)" + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python: ["3.10", "3.11", "3.12"] + os: [ubuntu-latest] + include: + - python: "3.12" + os: macos-latest + pip-flags: "--pre" + name: "Python 3.12 (pre-release)" - env: - OS: ${{ matrix.os }} - PYTHON: ${{ matrix.python }} + env: + OS: ${{ matrix.os }} + PYTHON: ${{ matrix.python }} - steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python }} + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} - - name: Get pip cache dir - id: pip-cache-dir - run: | - echo "::set-output name=dir::$(pip cache dir)" + - name: Get pip cache dir + id: pip-cache-dir + run: | + echo "::set-output name=dir::$(pip cache dir)" - - name: Restore pip cache - uses: actions/cache@v3 - with: - path: ${{ steps.pip-cache-dir.outputs.dir }} - key: pip-${{ runner.os }}-${{ env.pythonLocation }}-${{ hashFiles('**/requirements.txt') }} - restore-keys: | - pip-${{ runner.os }}-${{ env.pythonLocation }}- + - name: Restore pip cache + uses: actions/cache@v3 + with: + path: ${{ steps.pip-cache-dir.outputs.dir }} + key: pip-${{ runner.os }}-${{ env.pythonLocation }}-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + pip-${{ runner.os }}-${{ env.pythonLocation }}- - - name: Install dependencies - run: | - ./.scripts/ci/install_dependencies.sh + - name: Install dependencies + run: | + ./.scripts/ci/install_dependencies.sh - - name: Install pip dependencies - run: | - python -m pip install --upgrade pip - pip install tox tox-gh-actions + - name: Install pip dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions - - name: Restore data cache - id: data-cache - uses: actions/cache@v3 - with: - path: | - ~/.cache/squidpy/*.h5ad - key: data-${{ hashFiles('**/download_data.py') }} + - name: Restore data cache + id: data-cache + uses: actions/cache@v3 + with: + path: | + ~/.cache/squidpy/*.h5ad + key: data-${{ hashFiles('**/download_data.py') }} - - name: Download datasets - if: steps.data-cache.outputs.cache-hit != 'true' - run: | - tox -e download-data + - name: Download datasets + if: steps.data-cache.outputs.cache-hit != 'true' + run: | + tox -e download-data - # caching .tox is not encouraged, but since we're private and this shaves off ~1min from the step - # if any problems occur and/or once the package is public, this can be removed - - name: Restore tox cache - uses: actions/cache@v3 - with: - path: .tox - key: tox-${{ runner.os }}-${{ env.pythonLocation }}-${{ hashFiles('**/requirements.txt', '**/tox.ini') }} + # caching .tox is not encouraged, but since we're private and this shaves off ~1min from the step + # if any problems occur and/or once the package is public, this can be removed + - name: Restore tox cache + uses: actions/cache@v3 + with: + path: .tox + key: tox-${{ runner.os }}-${{ env.pythonLocation }}-${{ hashFiles('**/requirements.txt', '**/tox.ini') }} - - name: Test - timeout-minutes: 60 - env: - MPLBACKEND: agg - PLATFORM: ${{ matrix.os }} - DISPLAY: :42 - PYTEST_ADDOPTS: "-n auto" - run: | - tox -vv - # check if this can be deprecated - #- name: List figures for potential debugging - # ls -alh /home/runner/work/squidpy/squidpy/tests/figures + - name: Test + timeout-minutes: 60 + env: + MPLBACKEND: agg + PLATFORM: ${{ matrix.os }} + DISPLAY: :42 + PYTEST_ADDOPTS: "-n auto" + run: | + tox -vv + # check if this can be deprecated + #- name: List figures for potential debugging + # ls -alh /home/runner/work/squidpy/squidpy/tests/figures - - name: Archive figures generated during testing - if: always() - uses: actions/upload-artifact@v4 - with: - name: visual_test_results_${{ matrix.os }}-python${{ matrix.python }} - path: /home/runner/work/squidpy/squidpy/tests/figures/* + - name: Archive figures generated during testing + if: always() + uses: actions/upload-artifact@v4 + with: + name: visual_test_results_${{ matrix.os }}-python${{ matrix.python }} + path: /home/runner/work/squidpy/squidpy/tests/figures/* - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - with: - name: coverage - verbose: true - token: ${{ secrets.CODECOV_TOKEN }} # required + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + name: coverage + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} # required diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a44ee6e63..8b3406a2a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,26 +1,26 @@ fail_fast: false default_language_version: - python: python3 + python: python3 default_stages: - - pre-commit - - pre-push + - pre-commit + - pre-push minimum_pre_commit_version: 2.16.0 repos: - - repo: https://github.com/rbubley/mirrors-prettier - rev: v3.5.3 - hooks: - - id: prettier - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.7 - hooks: - - id: ruff - types_or: [python, pyi, jupyter] - args: [--fix, --exit-non-zero-on-fix] - - id: ruff-format - types_or: [python, pyi, jupyter] - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.15.0 - hooks: - - id: mypy - additional_dependencies: [numpy, types-requests] - exclude: tests/|docs/ + - repo: https://github.com/rbubley/mirrors-prettier + rev: v3.5.3 + hooks: + - id: prettier + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.7 + hooks: + - id: ruff + types_or: [python, pyi, jupyter] + args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format + types_or: [python, pyi, jupyter] + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.15.0 + hooks: + - id: mypy + additional_dependencies: [numpy, types-requests] + exclude: tests/|docs/ diff --git a/.readthedocs.yml b/.readthedocs.yml index 63373dd2b..45aef93bd 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,22 +1,22 @@ version: 2 build: - os: ubuntu-24.04 - tools: - python: "3.12" - commands: - - asdf plugin add uv - - asdf install uv latest - - asdf global uv latest - - uv venv - - uv pip install .[docs,pre] - - .venv/bin/python -m sphinx -T -b html -d docs/_build/doctrees -D language=en docs $READTHEDOCS_OUTPUT/html + os: ubuntu-24.04 + tools: + python: "3.12" + commands: + - asdf plugin add uv + - asdf install uv latest + - asdf global uv latest + - uv venv + - uv pip install .[docs,pre] + - .venv/bin/python -m sphinx -T -b html -d docs/_build/doctrees -D language=en docs $READTHEDOCS_OUTPUT/html sphinx: - builder: html - configuration: docs/conf.py - fail_on_warning: false + builder: html + configuration: docs/conf.py + fail_on_warning: false submodules: - include: [docs/notebooks] - recursive: true + include: [docs/notebooks] + recursive: true diff --git a/pyproject.toml b/pyproject.toml index 80615c34a..978b5de55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,12 +8,11 @@ dynamic = ["version"] description = "Spatial Single Cell Analysis in Python" readme = "README.rst" requires-python = ">=3.10" -license = {file = "LICENSE"} +license = "BSD-3-Clause" classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Science/Research", "Natural Language :: English", - "License :: OSI Approved :: BSD License", "Operating System :: POSIX :: Linux", "Operating System :: MacOS :: MacOS X", "Typing :: Typed", @@ -231,4 +230,4 @@ ban-relative-imports = "all" [tool.pytest.ini_options] filterwarnings = [ "error::numba.NumbaPerformanceWarning" -] \ No newline at end of file +] From b33fa5ed315cf4290d150da0da0be8b43289d282 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 15 Aug 2025 22:23:57 +0200 Subject: [PATCH 15/27] Update CI (#1025) * dedupe readme * everything except tox * tox-uv * no need to activate * actually use pre-release flag * add check for more flexibility for tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 17 +++---- .github/workflows/deployment.yml | 17 +++---- .github/workflows/release.yml | 16 +++---- .github/workflows/test.yml | 42 ++++++++--------- README_pypi.rst | 77 -------------------------------- pyproject.toml | 14 ++++-- 6 files changed, 49 insertions(+), 134 deletions(-) delete mode 100644 README_pypi.rst diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8df623435..7b3a3e228 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,16 +14,13 @@ jobs: package: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.10 - uses: actions/setup-python@v4 + - uses: actions/checkout@v5 + - name: Set up Python 3.12 + uses: astral-sh/setup-uv@v6 with: - python-version: "3.10" - cache: "pip" - cache-dependency-path: "**/pyproject.toml" - - name: Install build dependencies - run: python -m pip install --upgrade pip wheel twine build + python-version: "3.12" + enable-cache: true - name: Build package - run: python -m build + run: uvx hatch build - name: Check package - run: twine check --strict dist/*.whl + run: uvx twine check --strict dist/*.whl diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 3c1ddac98..53a10e31e 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -17,21 +17,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v5 with: fetch-depth: 0 - - name: Set up Python 3.10 - uses: actions/setup-python@v4 + - name: Set up Python 3.12 + uses: astral-sh/setup-uv@v6 with: - python-version: "3.10" - - - name: Install hatch - run: pip install hatch + python-version: "3.12" # this will fail if the last commit is not tagged - name: Build project for distribution - run: hatch build + run: uvx hatch build - name: Publish on PyPI uses: pypa/gh-action-pypi-publish@release/v1 @@ -46,14 +43,14 @@ jobs: needs: deploy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Extract branch name id: vars run: | echo ::set-output name=branch::${GITHUB_REF#refs/*/} - name: Merge release into main - uses: everlytic/branch-merge@1.1.2 + uses: everlytic/branch-merge@1.1.5 with: github_token: ${{ secrets.RELEASE_DISPATCH_TOKEN }} target_branch: main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 295bb3081..fc67f428d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,22 +9,16 @@ jobs: runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/v') steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Set up Python 3.12 - uses: actions/setup-python@v5 + uses: astral-sh/setup-uv@v6 with: python-version: "3.12" - cache: pip - - name: Install build dependencies - run: python -m pip install --upgrade pip wheel twine build + enable-cache: true - name: Build package - run: python -m build + run: uvx hatch build - name: Check package - run: twine check --strict dist/*.whl - - name: Install hatch - run: pip install hatch - - name: Build project for distribution - run: hatch build + run: uvx twine check --strict dist/*.whl - name: Publish a Python distribution to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2e277e200..689eef1c5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,45 +25,32 @@ jobs: include: - python: "3.12" os: macos-latest - pip-flags: "--pre" + pre-release: "allow" name: "Python 3.12 (pre-release)" env: OS: ${{ matrix.os }} PYTHON: ${{ matrix.python }} + UV_PRERELEASE: ${{ matrix.pre-release || 'disallow' }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v5 + uses: astral-sh/setup-uv@v6 with: python-version: ${{ matrix.python }} - - - name: Get pip cache dir - id: pip-cache-dir - run: | - echo "::set-output name=dir::$(pip cache dir)" - - - name: Restore pip cache - uses: actions/cache@v3 - with: - path: ${{ steps.pip-cache-dir.outputs.dir }} - key: pip-${{ runner.os }}-${{ env.pythonLocation }}-${{ hashFiles('**/requirements.txt') }} - restore-keys: | - pip-${{ runner.os }}-${{ env.pythonLocation }}- + enable-cache: true - name: Install dependencies run: | ./.scripts/ci/install_dependencies.sh - name: Install pip dependencies - run: | - python -m pip install --upgrade pip - pip install tox tox-gh-actions + run: uv tool install tox --with=tox-uv --with=tox-gh-actions - name: Restore data cache id: data-cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: | ~/.cache/squidpy/*.h5ad @@ -77,10 +64,10 @@ jobs: # caching .tox is not encouraged, but since we're private and this shaves off ~1min from the step # if any problems occur and/or once the package is public, this can be removed - name: Restore tox cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: .tox - key: tox-${{ runner.os }}-${{ env.pythonLocation }}-${{ hashFiles('**/requirements.txt', '**/tox.ini') }} + key: tox-${{ runner.os }}-${{ matrix.python }}-${{ hashFiles('**/requirements.txt', '**/tox.ini') }} - name: Test timeout-minutes: 60 @@ -103,8 +90,17 @@ jobs: path: /home/runner/work/squidpy/squidpy/tests/figures/* - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: name: coverage verbose: true token: ${{ secrets.CODECOV_TOKEN }} # required + + check: + if: always() + needs: [test] + runs-on: ubuntu-latest + steps: + - uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} diff --git a/README_pypi.rst b/README_pypi.rst deleted file mode 100644 index ec666dc80..000000000 --- a/README_pypi.rst +++ /dev/null @@ -1,77 +0,0 @@ -|PyPI| |Downloads| |CI| |Docs| |Coverage| |Discourse| |Zulip| - -Squidpy - Spatial Single Cell Analysis in Python -================================================ - -**Squidpy** is a tool for the analysis and visualization of spatial molecular data. -It builds on top of `scanpy`_ and `anndata`_, from which it inherits modularity and scalability. -It provides analysis tools that leverages the spatial coordinates of the data, as well as -tissue images if available. - -Visit our `documentation`_ for installation, tutorials, examples and more. - -Manuscript ----------- -Please see our manuscript `Palla, Spitzer et al. (2022)`_ in **Nature Methods** to learn more. - -Squidpy's key applications --------------------------- -- Build and analyze the neighborhood graph from spatial coordinates. -- Compute spatial statistics for cell-types and genes. -- Efficiently store, analyze and visualize large tissue images, leveraging `skimage`_. -- Interactively explore `anndata`_ and large tissue images in `napari`_. - -Installation ------------- -Install Squidpy via PyPI by running:: - - pip install squidpy - # or with napari included - pip install 'squidpy[interactive]' - -or via Conda as:: - - conda install -c conda-forge squidpy - -Contributing to Squidpy ------------------------ -We are happy about any contributions! Before you start, check out our `contributing guide `_. - -.. |PyPI| image:: https://img.shields.io/pypi/v/squidpy.svg - :target: https://pypi.org/project/squidpy/ - :alt: PyPI - -.. |CI| image:: https://img.shields.io/github/workflow/status/scverse/squidpy/Test/main - :target: https://github.com/scverse/squidpy/actions - :alt: CI - -.. |Pre-commit| image:: https://results.pre-commit.ci/badge/github/scverse/squidpy/main.svg - :target: https://results.pre-commit.ci/latest/github/scverse/squidpy/main - :alt: pre-commit.ci status - -.. |Docs| image:: https://img.shields.io/readthedocs/squidpy - :target: https://squidpy.readthedocs.io/en/stable/ - :alt: Documentation - -.. |Coverage| image:: https://codecov.io/gh/scverse/squidpy/branch/main/graph/badge.svg - :target: https://codecov.io/gh/scverse/squidpy - :alt: Coverage - -.. |Downloads| image:: https://pepy.tech/badge/squidpy - :target: https://pepy.tech/project/squidpy - :alt: Downloads - -.. |Discourse| image:: https://img.shields.io/discourse/posts?color=yellow&logo=discourse&server=https%3A%2F%2Fdiscourse.scverse.org - :target: https://discourse.scverse.org/ - :alt: Discourse - -.. |Zulip| image:: https://img.shields.io/badge/zulip-join_chat-%2367b08f.svg - :target: https://scverse.zulipchat.com - :alt: Zulip - -.. _Palla, Spitzer et al. (2022): https://doi.org/10.1038/s41592-021-01358-2 -.. _scanpy: https://scanpy.readthedocs.io/en/stable/ -.. _anndata: https://anndata.readthedocs.io/en/stable/ -.. _napari: https://napari.org/ -.. _skimage: https://scikit-image.org/ -.. _documentation: https://squidpy.readthedocs.io/en/stable/ diff --git a/pyproject.toml b/pyproject.toml index 978b5de55..f7de064f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,11 @@ [build-system] build-backend = "hatchling.build" -requires = ["hatchling", "hatch-vcs"] +requires = ["hatchling", "hatch-vcs", "hatch-fancy-pypi-readme"] [project] name = "squidpy" -dynamic = ["version"] +dynamic = ["readme", "version"] description = "Spatial Single Cell Analysis in Python" -readme = "README.rst" requires-python = ">=3.10" license = "BSD-3-Clause" classifiers = [ @@ -121,6 +120,15 @@ source = "vcs" [tool.hatch.metadata] allow-direct-references = true +[tool.hatch.metadata.hooks.fancy-pypi-readme] +content-type = "text/x-rst" +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +text = "|PyPI| |Downloads| |CI| |Docs| |Coverage| |Discourse| |Zulip|" +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +path = "README.rst" +start-after = "|NumFOCUS|" +end-before = "Squidpy is part of the scverse® project" + [tool.ruff] line-length = 120 exclude = [ From 75750a2c58f3cc6f00b4b352e3e01ed8860766a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Selman=20=C3=96zleyen?= <32667648+selmanozleyen@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:07:06 +0200 Subject: [PATCH 16/27] Moving to uv + hatch (#1029) * try to move to uv+hatch * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * update test.yml * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * problems of vibe coding :( * retry * try out this setting * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix2 * remove scanpy specific options * remove junit * remove juntxml uplaod * dont publish debug data and upload test data * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * upload figures * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * just slack the tests for less flaky tests * update assertion * update matrix * update matrix * update configs * ofc you can't set os in hatch * fix hatch * set notebook to main again * simplify test.yml * clean up * fix the yaml names * no need for build in test.yml * undo test change and remove tox.ini * fix macos dir error * fix archive filepath problem * add tolerance for tests/graph/test_ppatterns.py::test_spatial_autocorr_reproducibility[1-moran] * fix attempt for coverage * fix the path * don't specify .xml * match the versions so they are the same * undo the tolerance * update the python versions in hatch * point to old commit * replace bash script in CI to avoid .sh'es * use matrix.os instead of runner.os * redo scheduled job * remove unnecessary lines * remove scripts instead will use uv defaults * remove set -u * use hatch matrix * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix the bash code * fix names and where matrix is * update matrix os * incorperate the cache again as in old code * reduce number of versions and conditionally upload coverage * update docs * use v5 instead * the results aren't from version 6 * check if the floating point dep. is related to matrix list * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * update yml * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add 3.12 * fix the issue finally * change the versions * 3.11, 3.12, 3.13 ubuntu and 3.12 macos. bc macos is slow * check if this is the issue * pin versions * no pytest-cov * undo the version pin testing * Python source: 'from __future__ imports must occur at the beginning of the file' at line 10 * add tolerance and explain why * refer to issue instead of commit * Allow download fail on darwin * give it as condition expression * simplify the matrix and get rid of python 3.10 ci test * mark internet required --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Philipp A. --- .github/workflows/test.yml | 126 ++++++++++-------- .scripts/ci/install_dependencies.sh | 16 --- CONTRIBUTING.rst | 36 +++--- MANIFEST.in | 2 +- docs/notebooks | 2 +- hatch.toml | 59 +++++++++ pyproject.toml | 49 ++++++- src/squidpy/pl/_interactive/interactive.py | 4 +- tests/datasets/conftest.py | 11 ++ tests/datasets/test_dataset.py | 2 + tests/graph/test_ppatterns.py | 12 +- tox.ini | 142 --------------------- 12 files changed, 221 insertions(+), 240 deletions(-) delete mode 100755 .scripts/ci/install_dependencies.sh create mode 100644 hatch.toml create mode 100644 tests/datasets/conftest.py delete mode 100644 tox.ini diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 689eef1c5..061d30fc5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,18 +1,21 @@ -name: Test +name: CI on: schedule: - cron: 00 00 * * 1 # every Monday at 00:00 push: - branches: [main] + branches: + - main + - "[0-9]+.[0-9]+.x" pull_request: - branches: [main] - workflow_dispatch: - inputs: - reason: - description: Reason for the workflow dispatch. Only "release" is valid. - required: true - default: release + +env: + PYTEST_ADDOPTS: "-v --color=yes -n auto" + FORCE_COLOR: "1" + MPLBACKEND: agg + # It's impossible to ignore SyntaxWarnings for a single module, + # so because leidenalg 0.10.0 has them, we pre-compile things: https://github.com/vtraag/leidenalg/issues/173 + UV_COMPILE_BYTECODE: "1" jobs: test: @@ -20,33 +23,36 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.10", "3.11", "3.12"] - os: [ubuntu-latest] include: - - python: "3.12" + - name: hatch-test.py3.11-stable + os: ubuntu-latest + python: "3.11" + - name: hatch-test.py3.12-stable + os: ubuntu-latest + python: "3.12" + - name: hatch-test.py3.13-stable + os: ubuntu-latest + python: "3.13" + test-type: "coverage" + - name: hatch-test.py3.13-pre os: macos-latest - pre-release: "allow" - name: "Python 3.12 (pre-release)" - - env: - OS: ${{ matrix.os }} - PYTHON: ${{ matrix.python }} - UV_PRERELEASE: ${{ matrix.pre-release || 'disallow' }} - + python: "3.13" + env: # environment variable for use in codecov's env_vars tagging + ENV_NAME: ${{ matrix.name }} steps: - - uses: actions/checkout@v5 - - name: Set up Python ${{ matrix.python }} - uses: astral-sh/setup-uv@v6 + - uses: actions/checkout@v4 with: - python-version: ${{ matrix.python }} - enable-cache: true + fetch-depth: 0 + filter: blob:none - - name: Install dependencies - run: | - ./.scripts/ci/install_dependencies.sh + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + python-version: ${{ matrix.python }} + cache-dependency-glob: pyproject.toml - - name: Install pip dependencies - run: uv tool install tox --with=tox-uv --with=tox-gh-actions + - name: Ensure figure directory exists + run: mkdir -p "$GITHUB_WORKSPACE/tests/figures" - name: Restore data cache id: data-cache @@ -59,46 +65,54 @@ jobs: - name: Download datasets if: steps.data-cache.outputs.cache-hit != 'true' run: | - tox -e download-data + uvx hatch run ${{ matrix.name }}:download - # caching .tox is not encouraged, but since we're private and this shaves off ~1min from the step - # if any problems occur and/or once the package is public, this can be removed - - name: Restore tox cache - uses: actions/cache@v4 - with: - path: .tox - key: tox-${{ runner.os }}-${{ matrix.python }}-${{ hashFiles('**/requirements.txt', '**/tox.ini') }} + - name: System dependencies (Linux) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update -y + sudo apt-get install automake -y + + # PyQt5 related + sudo apt install libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 -y + sudo Xvfb :42 -screen 0 1920x1080x24 -ac +extension GLX ,`` to ignore certain ``flake8`` errors and ``# type: ignore[error1,error2]`` to ignore specific ``mypy`` errors. -To run only a subset of tests, run:: - tox -e -- -where ```` can be a path to a test file/directory or a name of a test function/class. -For example, to run only the tests in the ``nhood`` module, use:: - tox -e py39-linux -- tests/graph/test_nhood.py -If needed, a specific ``tox`` environment can be recreated as:: +[pytest]: https://docs.pytest.org/en/stable/ - tox -e --recreate Writing documentation --------------------- @@ -106,20 +105,25 @@ We use ``numpy``-style docstrings for the documentation with the following addit - prefer putting references in the ``references.bib`` instead under the ``References`` sections of the docstring. - use ``docrep`` for repeating documentation. -In order to build the documentation, run:: - tox -e docs +To build the docs, run run:: + hatch run docs:build + +Afterwards, you can run run:: + hatch run docs:open + +to open {file}`docs/_build/html/index.html`. Since the tutorials are hosted on a separate repository (see `Writing tutorials/examples`_), we download the newest tutorials/examples from there and build the documentation here. To validate the links inside the documentation, run:: - tox -e check-docs + hatch run docs:check If you need to clean the artifacts from previous documentation builds, run:: - tox -e clean-docs + hatch run docs:clean Writing tutorials/examples -------------------------- diff --git a/MANIFEST.in b/MANIFEST.in index b6215b32b..f67804b3c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,7 @@ include requirements.txt include LICENSE include README.rst -include tox.ini +include hatch.toml prune tests/_data prune tests/_images diff --git a/docs/notebooks b/docs/notebooks index 296295a16..1561fbc9d 160000 --- a/docs/notebooks +++ b/docs/notebooks @@ -1 +1 @@ -Subproject commit 296295a1682ad0f06757fe532a631803fad05c87 +Subproject commit 1561fbc9d0a33473087623d21b8ef427bed5ff51 diff --git a/hatch.toml b/hatch.toml new file mode 100644 index 000000000..e9a942f9a --- /dev/null +++ b/hatch.toml @@ -0,0 +1,59 @@ +[envs.default] +installer = "uv" +features = ["dev"] + +[envs.coverage] +extra-dependencies = [ + "coverage[toml]", + "diff_cover", +] + +[envs.coverage.scripts] +clean = "coverage erase" +report = "coverage report --omit='tox/*'" +xml = "coverage xml --omit='tox/*' -o coverage.xml" +diff = "diff-cover --compare-branch origin/main coverage.xml" + +[envs.docs] +features = ["docs"] +extra-dependencies = [ + "setuptools", +] + +[envs.docs.scripts] +build = "make -C docs html {args}" +clean = "make -C docs clean" +check = "make -C docs linkcheck {args}" + +[envs.data] +[envs.data.scripts] +download = "python ./.scripts/ci/download_data.py {args}" + + +[envs.hatch-test] +features = ["test"] +extra-dependencies = [ + "pytest", + "pytest-xdist", + "pytest-cov", + "pytest-mock", + "pytest-timeout", +] + + + +[[envs.hatch-test.matrix]] +deps = ["stable"] +python = ["3.11", "3.12", "3.13"] + +# Test the newest supported Python version also with pre-release deps +[[envs.hatch-test.matrix]] +deps = [ "pre" ] +python = [ "3.13" ] + +[envs.hatch-test.overrides] +# If the matrix variable `deps` is set to "pre", +# set the environment variable `UV_PRERELEASE` to "allow". +matrix.deps.env-vars = [ + { key = "UV_PRERELEASE", value = "allow", if = [ "pre" ] }, +] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index f7de064f7..eeb9ffbf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,13 +77,14 @@ dependencies = [ [project.optional-dependencies] dev = [ "pre-commit>=3.0.0", - "tox>=4.0.0", + "hatch>=1.9.0", ] test = [ "scanpy[leiden]", "pytest>=7", "pytest-xdist>=3", "pytest-mock>=3.5.0", + # Just for VS Code "pytest-cov>=4", "coverage[toml]>=7", "pytest-timeout>=2.1.0", @@ -101,7 +102,6 @@ docs = [ "myst-nb>=0.17.1", "sphinx_copybutton>=0.5.0", ] - [project.urls] Homepage = "https://github.com/scverse/squidpy" "Bug Tracker" = "https://github.com/scverse/squidpy/issues" @@ -120,6 +120,9 @@ source = "vcs" [tool.hatch.metadata] allow-direct-references = true +[tool.hatch.build.targets.wheel] +packages = ["src/squidpy"] + [tool.hatch.metadata.hooks.fancy-pypi-readme] content-type = "text/x-rst" [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] @@ -237,5 +240,45 @@ ban-relative-imports = "all" [tool.pytest.ini_options] filterwarnings = [ - "error::numba.NumbaPerformanceWarning" + "error::numba.NumbaPerformanceWarning", + "ignore::UserWarning", + "ignore::anndata.OldFormatWarning", + "ignore:.*pkg_resources:DeprecationWarning", +] +python_files = "test_*.py" +testpaths = ["tests/"] +xfail_strict = true +addopts = [ + "--ignore=tests/plotting/test_interactive.py", + "--ignore=docs", +] + + +[tool.coverage.run] +branch = true +parallel = true +source = ["squidpy"] +omit = [ + "*/__init__.py", + "*/_version.py", + "squidpy/pl/_interactive/*", +] + +[tool.coverage.paths] +source = [ + "squidpy", + "*/site-packages/squidpy", +] + +[tool.coverage.report] +exclude_lines = [ + "\\#.*pragma:\\s*no.?cover", + "^if __name__ == .__main__.:$", + "^\\s*raise AssertionError\\b", + "^\\s*raise NotImplementedError\\b", + "^\\s*return NotImplemented\\b", ] +show_missing = true +precision = 2 +skip_empty = true +sort = "Miss" \ No newline at end of file diff --git a/src/squidpy/pl/_interactive/interactive.py b/src/squidpy/pl/_interactive/interactive.py index 25aea6f41..34cfbc149 100644 --- a/src/squidpy/pl/_interactive/interactive.py +++ b/src/squidpy/pl/_interactive/interactive.py @@ -1,3 +1,5 @@ +from __future__ import annotations + """ interactive.py @@ -7,8 +9,6 @@ raise ImportError("The squidpy napari plugin is deprecated, please use https://github.com/scverse/napari-spatialdata") -from __future__ import annotations - from typing import ( Any, Union, # noqa: F401 diff --git a/tests/datasets/conftest.py b/tests/datasets/conftest.py new file mode 100644 index 000000000..ebdc1c493 --- /dev/null +++ b/tests/datasets/conftest.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +import sys + +import pytest + + +@pytest.fixture(autouse=True) +def _xfail_internet_if_macos(request: pytest.FixtureRequest) -> None: + if request.node.get_closest_marker("internet") and sys.platform == "darwin": + request.applymarker(pytest.mark.xfail(reason="Downloads fail on macOS", strict=False)) diff --git a/tests/datasets/test_dataset.py b/tests/datasets/test_dataset.py index 4c0c958eb..f940f5090 100644 --- a/tests/datasets/test_dataset.py +++ b/tests/datasets/test_dataset.py @@ -23,6 +23,7 @@ def test_import(self, func): # TODO(michalk8): parse the code and xfail iff server issue class TestDatasetsDownload: @pytest.mark.timeout(120) + @pytest.mark.internet() def test_download_imc(self, tmp_path: Path): with warnings.catch_warnings(): warnings.simplefilter("ignore", category=OldFormatWarning) @@ -35,6 +36,7 @@ def test_download_imc(self, tmp_path: Path): pytest.xfail(str(e)) @pytest.mark.timeout(120) + @pytest.mark.internet() def test_download_visium_hne_image_crop(self, tmp_path: Path): with warnings.catch_warnings(): warnings.simplefilter("ignore", category=OldFormatWarning) diff --git a/tests/graph/test_ppatterns.py b/tests/graph/test_ppatterns.py index 6752b1548..226fb2830 100644 --- a/tests/graph/test_ppatterns.py +++ b/tests/graph/test_ppatterns.py @@ -36,7 +36,10 @@ def test_spatial_autocorr_seq_par(dummy_adata: AnnData, mode: str): assert dummy_adata.uns[UNS_KEY].columns.shape == (4,) assert df.columns.shape == (9,) # test pval_norm same - np.testing.assert_array_equal(df["pval_norm"].values, df_parallel["pval_norm"].values) + # will need to increase the tolerance because numba parallel computations might not be exactly the same + # these pval_norms don't use the seed anyway so the difference is not due to the seed + # see https://github.com/scverse/squidpy/issues/1030 for more details + np.testing.assert_allclose(df["pval_norm"].values, df_parallel["pval_norm"].values, atol=1e-12) # test highly variable assert dummy_adata.uns[UNS_KEY].shape != df.shape # assert idx are sorted and contain same elements @@ -71,8 +74,11 @@ def test_spatial_autocorr_reproducibility(dummy_adata: AnnData, n_jobs: int, mod assert "pval_sim_fdr_bh" in df_1 assert "pval_norm_fdr_bh" in dummy_adata.uns[UNS_KEY] # test pval_norm same - np.testing.assert_array_equal(df_1["pval_norm"].values, df_2["pval_norm"].values) - np.testing.assert_array_equal(df_1["var_norm"].values, df_2["var_norm"].values) + # will need to increase the tolerance because numba parallel computations might not be exactly the same + # see https://github.com/scverse/squidpy/issues/1030 for more details about the tolerance + # these pval_norms don't use the seed anyway so the difference is not due to the seed + np.testing.assert_allclose(df_1["pval_norm"].values, df_2["pval_norm"].values, atol=1e-12) + np.testing.assert_allclose(df_1["var_norm"].values, df_2["var_norm"].values, atol=1e-12) assert dummy_adata.uns[UNS_KEY].columns.shape == (4,) assert df_2.columns.shape == (9,) # test highly variable diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 41aafd937..000000000 --- a/tox.ini +++ /dev/null @@ -1,142 +0,0 @@ -[pytest] -python_files = test_*.py -testpaths = tests/ -xfail_strict = true -; qt_api=pyqt5 -addopts = - --ignore=tests/plotting/test_interactive.py - -filterwarnings = - ignore::UserWarning - ignore::anndata.OldFormatWarning - ignore:.*pkg_resources:DeprecationWarning - -[coverage:run] -branch = true -parallel = true -source = squidpy -omit = - */__init__.py - */_version.py - squidpy/pl/_interactive/* - -[coverage:paths] -source = - squidpy - */site-packages/squidpy - -[coverage:report] -exclude_lines = - \#.*pragma:\s*no.?cover - - ^if __name__ == .__main__.:$ - - ^\s*raise AssertionError\b - ^\s*raise NotImplementedError\b - ^\s*return NotImplemented\b -show_missing = true -precision = 2 -skip_empty = True -sort = Miss - -[gh-actions] -python = - 3.10: py3.10 - 3.11: py3.11 - 3.12: py3.12 - -[gh-actions:env] -PLATFORM = - ubuntu-latest: linux - macos-latest: macos - -[tox] -isolated_build = True -envlist = - covclean - py3.10-linux - py3.11-linux - py3.12-linux - py3.12-macos - coverage - readme - check-docs - docs -skip_missing_interpreters = true - -[testenv] -platform = - linux: linux - macos: (osx|darwin) -deps = - pytest - pytest-xdist - pytest-cov - ; pytest-qt - pytest-mock - pytest-timeout -# see: https://github.com/numba/llvmlite/issues/669 -extras = - interactive - test -setenv = linux: PYTEST_FLAGS=--test-napari -passenv = TOXENV,CI,CODECOV_*,GITHUB_ACTIONS,PYTEST_FLAGS,DISPLAY,XAUTHORITY,MPLBACKEND,PYTEST_ADDOPTS -usedevelop = true -commands = - python -m pytest --color=yes --cov --cov-append --cov-report=xml --cov-config={toxinidir}/tox.ini --ignore docs/ {posargs:-vv} {env:PYTEST_FLAGS:} - -[testenv:covclean] -description = Clean coverage files. -deps = coverage -skip_install = True -commands = coverage erase - -[testenv:coverage] -description = Report the coverage difference. -deps = - coverage - diff_cover -skip_install = true -depends = py3.10-linux, py3.11-linux, py3.12-linux, py3.12-macos -parallel_show_output = True -commands = - coverage report --omit="tox/*" - coverage xml --omit="tox/*" -o {toxinidir}/coverage.xml - diff-cover --compare-branch origin/main {toxinidir}/coverage.xml - -[testenv:clean-docs] -description = Clean the documentation artifacts. -deps = -skip_install = true -changedir = {toxinidir}/docs -allowlist_externals = make -commands = make clean - -[testenv:check-docs] -description = Lint the documentation. -deps = -extras = docs -ignore_errors = true -allowlist_externals = make -pass_env = PYENCHANT_LIBRARY_PATH -set_env = SPHINXOPTS = -W -q --keep-going -changedir = {tox_root}{/}docs -commands = - make linkcheck {posargs} - -[testenv:docs] -description = Build the documentation. -deps = -extras = docs -allowlist_externals = make -changedir = {tox_root}{/}docs -commands = - make html {posargs} -commands_post = - python -c 'import pathlib; print("Documentation is under:", pathlib.Path("{tox_root}") / "docs" / "_build" / "html" / "index.html")' - -[testenv:download-data] -description = Download and cache data. -skip_install = false -deps = -commands = python ./.scripts/ci/download_data.py {posargs} From d7961056436a93c07cafb228d7cc726bac476879 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Selman=20=C3=96zleyen?= <32667648+selmanozleyen@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:40:45 +0200 Subject: [PATCH 17/27] bump version (#1031) --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eeb9ffbf1..e8968966c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ requires = ["hatchling", "hatch-vcs", "hatch-fancy-pypi-readme"] name = "squidpy" dynamic = ["readme", "version"] description = "Spatial Single Cell Analysis in Python" -requires-python = ">=3.10" +requires-python = ">=3.11" license = "BSD-3-Clause" classifiers = [ "Development Status :: 5 - Production/Stable", @@ -16,9 +16,9 @@ classifiers = [ "Operating System :: MacOS :: MacOS X", "Typing :: Typed", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Environment :: Console", "Framework :: Jupyter", "Intended Audience :: Science/Research", From 77a97db79d6899fb27a6b5386ebaa0facfec1922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Selman=20=C3=96zleyen?= <32667648+selmanozleyen@users.noreply.github.com> Date: Wed, 1 Oct 2025 15:03:53 +0200 Subject: [PATCH 18/27] Replacing fixed size tuples as return types (#1043) * change return types and update tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix mypy problems * fix typing --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- src/squidpy/gr/__init__.py | 25 ++++++++++++++++++++++-- src/squidpy/gr/_build.py | 23 ++++++++++++++++++---- src/squidpy/gr/_nhood.py | 28 ++++++++++++++++++++++----- src/squidpy/im/_io.py | 6 +++--- tests/graph/test_nhood.py | 15 +++++++------- tests/graph/test_spatial_neighbors.py | 22 ++++++++++----------- 6 files changed, 86 insertions(+), 33 deletions(-) diff --git a/src/squidpy/gr/__init__.py b/src/squidpy/gr/__init__.py index 0122a2494..7bb9bb4a2 100644 --- a/src/squidpy/gr/__init__.py +++ b/src/squidpy/gr/__init__.py @@ -2,10 +2,31 @@ from __future__ import annotations -from squidpy.gr._build import mask_graph, spatial_neighbors +from squidpy.gr._build import SpatialNeighborsResult, mask_graph, spatial_neighbors from squidpy.gr._ligrec import ligrec -from squidpy.gr._nhood import centrality_scores, interaction_matrix, nhood_enrichment +from squidpy.gr._nhood import ( + NhoodEnrichmentResult, + centrality_scores, + interaction_matrix, + nhood_enrichment, +) from squidpy.gr._niche import calculate_niche from squidpy.gr._ppatterns import co_occurrence, spatial_autocorr from squidpy.gr._ripley import ripley from squidpy.gr._sepal import sepal + +__all__ = [ + "SpatialNeighborsResult", + "NhoodEnrichmentResult", + "mask_graph", + "spatial_neighbors", + "ligrec", + "centrality_scores", + "interaction_matrix", + "nhood_enrichment", + "calculate_niche", + "co_occurrence", + "spatial_autocorr", + "ripley", + "sepal", +] diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index 2f001905a..e4c743ed3 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -6,7 +6,7 @@ from collections.abc import Iterable # noqa: F401 from functools import partial from itertools import chain -from typing import Any, cast +from typing import Any, NamedTuple, cast import geopandas as gpd import numpy as np @@ -61,6 +61,21 @@ __all__ = ["spatial_neighbors"] +class SpatialNeighborsResult(NamedTuple): + """Result of spatial_neighbors function. + + Attributes + ---------- + connectivities + Spatial connectivities matrix. + distances + Spatial distances matrix. + """ + + connectivities: csr_matrix + distances: csr_matrix + + @d.dedent @inject_docs(t=Transform, c=CoordType) def spatial_neighbors( @@ -79,7 +94,7 @@ def spatial_neighbors( set_diag: bool = False, key_added: str = "spatial", copy: bool = False, -) -> tuple[csr_matrix, csr_matrix] | None: +) -> SpatialNeighborsResult | None: """ Create a graph from spatial coordinates. @@ -136,7 +151,7 @@ def spatial_neighbors( Returns ------- - If ``copy = True``, returns a :class:`tuple` with the spatial connectivities and distances matrices. + If ``copy = True``, returns a :class:`~squidpy.gr.SpatialNeighborsResult` with the spatial connectivities and distances matrices. Otherwise, modifies the ``adata`` with the following keys: @@ -259,7 +274,7 @@ def spatial_neighbors( } if copy: - return Adj, Dst + return SpatialNeighborsResult(connectivities=Adj, distances=Dst) _save_data(adata, attr="obsp", key=conns_key, data=Adj) _save_data(adata, attr="obsp", key=dists_key, data=Dst, prefix=False) diff --git a/src/squidpy/gr/_nhood.py b/src/squidpy/gr/_nhood.py index d8f09ed1d..5ff39e6d2 100644 --- a/src/squidpy/gr/_nhood.py +++ b/src/squidpy/gr/_nhood.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Iterable, Sequence from functools import partial -from typing import Any +from typing import Any, NamedTuple import networkx as nx import numba.types as nt @@ -12,6 +12,7 @@ import pandas as pd from anndata import AnnData from numba import njit, prange # noqa: F401 +from numpy.typing import NDArray from pandas import CategoricalDtype from scanpy import logging as logg from spatialdata import SpatialData @@ -30,7 +31,24 @@ __all__ = ["nhood_enrichment", "centrality_scores", "interaction_matrix"] -dt = nt.uint32 # data type aliases (both for numpy and numba should match) + +class NhoodEnrichmentResult(NamedTuple): + """Result of nhood_enrichment function. + + Attributes + ---------- + zscore : NDArray[np.number] + Z-score values of enrichment statistic. + count : NDArray[np.number] + Enrichment count. + """ + + zscore: NDArray[np.number] + counts: NDArray[np.number] # NamedTuple inherits from tuple so cannot use 'count' as attribute name + + +# data type aliases (both for numpy and numba should match) +dt = nt.uint32 ndt = np.uint32 _template = """ @njit(dt[:, :](dt[:], dt[:], dt[:]), parallel={parallel}, fastmath=True) @@ -131,7 +149,7 @@ def nhood_enrichment( n_jobs: int | None = None, backend: str = "loky", show_progress_bar: bool = True, -) -> tuple[NDArrayA, NDArrayA] | None: +) -> NhoodEnrichmentResult | None: """ Compute neighborhood enrichment by permutation test. @@ -149,7 +167,7 @@ def nhood_enrichment( Returns ------- - If ``copy = True``, returns a :class:`tuple` with the z-score and the enrichment count. + If ``copy = True``, returns a :class:`~squidpy.gr.NhoodEnrichmentResult` with the z-score and the enrichment count. Otherwise, modifies the ``adata`` with the following keys: @@ -202,7 +220,7 @@ def nhood_enrichment( zscore = (count - perms.mean(axis=0)) / perms.std(axis=0) if copy: - return zscore, count + return NhoodEnrichmentResult(zscore=zscore, counts=count) _save_data( adata, diff --git a/src/squidpy/im/_io.py b/src/squidpy/im/_io.py index 3f092470b..c3ce5bce1 100644 --- a/src/squidpy/im/_io.py +++ b/src/squidpy/im/_io.py @@ -25,7 +25,7 @@ def _assert_dims_present(dims: tuple[str, ...], include_z: bool = True) -> None: # modification of `skimage`'s `pil_to_ndarray`: # https://github.com/scikit-image/scikit-image/blob/main/skimage/io/_plugins/pil_plugin.py#L55 -def _infer_shape_dtype(fname: str) -> tuple[tuple[int, ...], np.dtype]: +def _infer_shape_dtype(fname: str) -> tuple[tuple[int, ...], np.dtype[np.generic]]: def _palette_is_grayscale(pil_image: Image.Image) -> bool: # get palette as an array with R, G, B columns palette = np.asarray(pil_image.getpalette()).reshape((256, 3)) @@ -81,7 +81,7 @@ def _palette_is_grayscale(pil_image: Image.Image) -> bool: raise ValueError(f"Unable to infer image dtype for image mode `{image.mode}`.") -def _get_image_shape_dtype(fname: str) -> tuple[tuple[int, ...], np.dtype]: +def _get_image_shape_dtype(fname: str) -> tuple[tuple[int, ...], np.dtype[np.generic]]: try: return _infer_shape_dtype(fname) except Image.UnidentifiedImageError as e: @@ -101,7 +101,7 @@ def _get_image_shape_dtype(fname: str) -> tuple[tuple[int, ...], np.dtype]: def _infer_dimensions( obj: NDArrayA | xr.DataArray | str, infer_dimensions: InferDimensions | tuple[str, ...] = InferDimensions.DEFAULT, -) -> tuple[tuple[int, ...], tuple[str, ...], np.dtype, tuple[int, ...]]: +) -> tuple[tuple[int, ...], tuple[str, ...], np.dtype[np.generic], tuple[int, ...]]: """ Infer dimension names of an array. diff --git a/tests/graph/test_nhood.py b/tests/graph/test_nhood.py index a89074b99..74764f236 100644 --- a/tests/graph/test_nhood.py +++ b/tests/graph/test_nhood.py @@ -49,13 +49,14 @@ def test_reproducibility(self, adata: AnnData, n_jobs: int): assert len(res1) == len(res2) assert len(res2) == len(res3) - for key in range(len(res1)): - np.testing.assert_array_equal(res2[key], res1[key]) - if key == 0: # z-score - with pytest.raises(AssertionError): - np.testing.assert_array_equal(res3[key], res2[key]) - else: # counts - np.testing.assert_array_equal(res3[key], res2[key]) + # Test that the same seed produces the same results + np.testing.assert_array_equal(res2.zscore, res1.zscore) + np.testing.assert_array_equal(res2.counts, res1.counts) + + # Test that different seeds produce different z-scores but same counts + with pytest.raises(AssertionError): + np.testing.assert_array_equal(res3.zscore, res2.zscore) + np.testing.assert_array_equal(res3.counts, res2.counts) def test_centrality_scores(nhood_data: AnnData): diff --git a/tests/graph/test_spatial_neighbors.py b/tests/graph/test_spatial_neighbors.py index af7afeb01..ac4bd6a65 100644 --- a/tests/graph/test_spatial_neighbors.py +++ b/tests/graph/test_spatial_neighbors.py @@ -198,25 +198,23 @@ def test_radius_min_max(self, non_visium_adata: AnnData, radius: tuple[float, fl np.testing.assert_allclose(spatial_dist, gt_ddist) def test_copy(self, non_visium_adata: AnnData): - conn, dist = spatial_neighbors(non_visium_adata, delaunay=True, coord_type=None, copy=True) + result = spatial_neighbors(non_visium_adata, delaunay=True, coord_type=None, copy=True) - assert isspmatrix_csr(conn) - assert isspmatrix_csr(dist) + assert isspmatrix_csr(result.connectivities) + assert isspmatrix_csr(result.distances) assert Key.obsp.spatial_conn() not in non_visium_adata.obsp assert Key.obsp.spatial_dist() not in non_visium_adata.obsp - np.testing.assert_allclose(dist.toarray(), self._gt_ddist) - np.testing.assert_allclose(conn.toarray(), self._gt_dgraph) + np.testing.assert_allclose(result.distances.toarray(), self._gt_ddist) + np.testing.assert_allclose(result.connectivities.toarray(), self._gt_dgraph) @pytest.mark.parametrize("percentile", [99.0, 95.0]) def test_percentile_filtering(self, adata_hne: AnnData, percentile: float, coord_type="generic"): - conn, dist = spatial_neighbors(adata_hne, coord_type=coord_type, copy=True) - conn_filtered, dist_filtered = spatial_neighbors( - adata_hne, coord_type=coord_type, percentile=percentile, copy=True - ) + result = spatial_neighbors(adata_hne, coord_type=coord_type, copy=True) + result_filtered = spatial_neighbors(adata_hne, coord_type=coord_type, percentile=percentile, copy=True) # check whether there are less connectivities in the filtered graph and whether the max distance is smaller - assert not ((conn != conn_filtered).nnz == 0) - assert dist.max() > dist_filtered.max() + assert not ((result.connectivities != result_filtered.connectivities).nnz == 0) + assert result.distances.max() > result_filtered.distances.max() Adj, Dst = _build_connectivity(adata_hne.obsm["spatial"], n_neighs=6, return_distance=True, set_diag=False) threshold = np.percentile(Dst.data, percentile) @@ -225,7 +223,7 @@ def test_percentile_filtering(self, adata_hne: AnnData, percentile: float, coord Adj.eliminate_zeros() Dst.eliminate_zeros() - assert dist_filtered.max() == Dst.max() + assert result_filtered.distances.max() == Dst.max() @pytest.mark.parametrize("n_neighs", [5, 10, 20]) def test_spatial_neighbors_generic(self, n_neighs: int): From 2f65047dec3251e0b0dba87a188633ace561de93 Mon Sep 17 00:00:00 2001 From: LucaMarconato <2664412+LucaMarconato@users.noreply.github.com> Date: Tue, 7 Oct 2025 18:08:42 +0200 Subject: [PATCH 19/27] zarr v3 support (#1040) --- pyproject.toml | 4 ++-- tests/conftest.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e8968966c..fde05acf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,7 @@ dependencies = [ "tqdm>=4.50.2", "validators>=0.18.2", "xarray>=2024.10.0", - "zarr>=2.6.1,<3.0.0", + "zarr>=2.6.1", "spatialdata>=0.2.5", ] @@ -281,4 +281,4 @@ exclude_lines = [ show_missing = true precision = 2 skip_empty = true -sort = "Miss" \ No newline at end of file +sort = "Miss" diff --git a/tests/conftest.py b/tests/conftest.py index 016d20d90..c14f00892 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -376,7 +376,7 @@ def sdata_mask_graph(): "region_key": "region", "instance_key": "instance_id", } - return sd.SpatialData.from_elements_dict( + return sd.SpatialData.init_from_elements( { "circles": sd.models.ShapesModel().parse(points_df), "polygon": sd.models.ShapesModel().parse(polygon_df), From f8f358fad713b4380cf1c06946914cf0bf9ec50c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Selman=20=C3=96zleyen?= <32667648+selmanozleyen@users.noreply.github.com> Date: Thu, 16 Oct 2025 13:35:19 +0200 Subject: [PATCH 20/27] Fix: sepal numba compilation options changes the results significantly (#1045) * add numerical stability * simplify unnecessary parameters and arrays * Update src/squidpy/gr/_sepal.py Co-authored-by: Philipp A. * add documentation --------- Co-authored-by: Philipp A. --- src/squidpy/gr/_sepal.py | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/squidpy/gr/_sepal.py b/src/squidpy/gr/_sepal.py index 95d740997..a5260050c 100644 --- a/src/squidpy/gr/_sepal.py +++ b/src/squidpy/gr/_sepal.py @@ -202,14 +202,13 @@ def _score_helper( @njit(fastmath=True) def _diffusion( conc: NDArrayA, - laplacian: Callable[[NDArrayA, NDArrayA, NDArrayA], float], + laplacian: Callable[[NDArrayA, NDArrayA], float], n_iter: int, sat: NDArrayA, sat_idx: NDArrayA, unsat: NDArrayA, unsat_idx: NDArrayA, dt: float = 0.001, - D: float = 1.0, thresh: float = 1e-8, ) -> float: """Simulate diffusion process on a regular graph.""" @@ -217,15 +216,14 @@ def _diffusion( entropy_arr = np.zeros(n_iter) prev_ent = 1.0 nhood = np.zeros(sat_shape) - weights = np.ones(sat_shape) for i in range(n_iter): for j in range(sat_shape): nhood[j] = np.sum(conc[sat_idx[j]]) - d2 = laplacian(conc[sat], nhood, weights) + d2 = laplacian(conc[sat], nhood) dcdt = np.zeros(conc_shape) - dcdt[sat] = D * d2 + dcdt[sat] = d2 conc[sat] += dcdt[sat] * dt conc[unsat] += dcdt[unsat_idx] * dt # set values below zero to 0 @@ -246,7 +244,6 @@ def _diffusion( def _laplacian_rect( centers: NDArrayA, nbrs: NDArrayA, - h: float, ) -> NDArrayA: """ Five point stencil approximation on rectilinear grid. @@ -254,8 +251,6 @@ def _laplacian_rect( See `Wikipedia `_ for more information. """ d2f: NDArrayA = nbrs - 4 * centers - d2f = d2f / h**2 - return d2f @@ -264,7 +259,6 @@ def _laplacian_rect( def _laplacian_hex( centers: NDArrayA, nbrs: NDArrayA, - h: float, ) -> NDArrayA: """ Seven point stencil approximation on hexagonal grid. @@ -275,10 +269,7 @@ def _laplacian_hex( Curtis D. Benster, L.V. Kantorovich, V.I. Krylov, ISBN-13: 978-0486821603. """ - d2f: NDArrayA = nbrs - 6 * centers - d2f = d2f / h**2 - d2f = (d2f * 2) / 3 - + d2f: NDArrayA = (2.0 * nbrs - 12.0 * centers) / 3.0 return d2f @@ -287,11 +278,18 @@ def _laplacian_hex( def _entropy( xx: NDArrayA, ) -> float: - """Get entropy of an array.""" + """Compute Shannon entropy of an array of probability values (in nats).""" xnz = xx[xx > 0] xs: np.float64 = np.sum(xnz) + eps = np.finfo(np.float64).eps # ~2.22e-16 + if xs < eps: + # 0 because + # xn represents probabilities + # and p(x)=0 is taken as 0 entropy + # see https://stats.stackexchange.com/a/433096 + return 0.0 xn = xnz / xs - xl = np.log(xn) + xl = np.log(np.maximum(xn, eps)) return float((-xl * xn).sum()) From 0ff2ccba909d67b3303c5c4f26f4c2b6ef349158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Selman=20=C3=96zleyen?= <32667648+selmanozleyen@users.noreply.github.com> Date: Tue, 21 Oct 2025 15:50:05 +0200 Subject: [PATCH 21/27] Run notebooks in CI (#1013) * init * add yaml file * fix tox ini file * update tox deps * since the notebooks work add others to test * remove the tutorials folder as it has too much dependencies * convert to python * use uv and hatch for notebooks only * need to add notebook dependencies now * add yaml file * fix tox ini file * update tox deps * since the notebooks work add others to test * remove the tutorials folder as it has too much dependencies * convert to python * update with new rebase * specify path * remove .run_notebooks * no need to specify uv anymore * remove toxini and make sure pyproject is same as main * add step to setup squidpy kernel * fix syntax err * update the notebook commit * update the nb commit for rendering * update the home page to add new section --- .github/workflows/test-notebooks.yml | 46 +++++++++++++ .scripts/ci/run_notebooks.py | 96 ++++++++++++++++++++++++++++ docs/index.rst | 1 + hatch.toml | 18 +++++- 4 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test-notebooks.yml create mode 100644 .scripts/ci/run_notebooks.py diff --git a/.github/workflows/test-notebooks.yml b/.github/workflows/test-notebooks.yml new file mode 100644 index 000000000..e18ded7c8 --- /dev/null +++ b/.github/workflows/test-notebooks.yml @@ -0,0 +1,46 @@ +name: Test notebooks + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + python: ["3.11"] + + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python }} + + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + python-version: ${{ matrix.python }} + cache-dependency-glob: pyproject.toml + - name: Create notebooks environment + run: uvx hatch -v env create notebooks + - name: Test notebooks + env: + MPLBACKEND: agg + PLATFORM: ${{ matrix.os }} + DISPLAY: :42 + + run: | + uvx hatch run notebooks:setup-squidpy-kernel + uvx hatch run notebooks:run-notebooks diff --git a/.scripts/ci/run_notebooks.py b/.scripts/ci/run_notebooks.py new file mode 100644 index 000000000..8ab221a32 --- /dev/null +++ b/.scripts/ci/run_notebooks.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import glob +import os +import subprocess +import sys + +EPILOG = """ +Examples: + python run_notebooks.py docs/notebooks + python run_notebooks.py /path/to/notebooks --kernel my-kernel +""" + + +def main() -> None: + # Set up argument parser + parser = argparse.ArgumentParser( + description="Run Jupyter notebooks in specified directories using jupytext", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=EPILOG, + ) + + parser.add_argument("base_directory", help="Base directory containing notebook subdirectories") + + parser.add_argument( + "-k", "--kernel", default="squidpy", help="Jupyter kernel to use for execution (default: squidpy)" + ) + + parser.add_argument( + "--dry-run", action="store_true", help="Show which notebooks would be run without executing them" + ) + + args = parser.parse_args() + + # Base directory for notebooks + base_dir = args.base_directory + + # Define notebook directories or patterns + notebook_patterns = [ + f"{base_dir}/examples/tools/*.ipynb", + f"{base_dir}/examples/plotting/*.ipynb", + f"{base_dir}/examples/image/*.ipynb", + f"{base_dir}/examples/graph/*.ipynb", + # f"{base_dir}/tutorials/*.ipynb" # don't include because it contains many external modules + ] + + # Initialize a list to hold valid notebook paths + valid_notebooks = [] + + # Gather all valid notebook files from the patterns + print("Gathering notebooks...") + for pattern in notebook_patterns: + for nb_path in glob.glob(pattern): + if os.path.isfile(nb_path): # Check if the file exists + valid_notebooks.append(nb_path) # Add to the list of valid notebooks + + # Check if we have any notebooks to run + if len(valid_notebooks) == 0: + print("No notebooks found to run.") + sys.exit(1) + + # Echo the notebooks that will be run for clarity + print("Preparing to run the following notebooks:") + for nb in valid_notebooks: + print(f" {nb}") + + # If dry run, exit here + if args.dry_run: + print(f"\nDry run complete. Would execute {len(valid_notebooks)} notebooks with kernel '{args.kernel}'.") + return + + # Initialize a flag to track the success of all commands + all_success = True + + # Execute all valid notebooks + print(f"\nExecuting notebooks with kernel '{args.kernel}'...") + for nb in valid_notebooks: + print(f"Running {nb}") + try: + subprocess.run(["jupytext", "-k", args.kernel, "--execute", nb], check=True) + except subprocess.CalledProcessError: + print(f"Failed to run {nb}") + all_success = False + + # Check if any executions failed + if not all_success: + print("One or more notebooks failed to execute.") + sys.exit(1) + + print("All notebooks executed successfully.") + + +if __name__ == "__main__": + main() diff --git a/docs/index.rst b/docs/index.rst index ebc70d665..8f97ee5f9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -77,6 +77,7 @@ We are happy about any contributions! Before you start, check out our `contribut notebooks/tutorials/index notebooks/examples/index + notebooks/deprecated_features/index .. |PyPI| image:: https://img.shields.io/pypi/v/squidpy.svg :target: https://pypi.org/project/squidpy/ diff --git a/hatch.toml b/hatch.toml index e9a942f9a..388594bf6 100644 --- a/hatch.toml +++ b/hatch.toml @@ -56,4 +56,20 @@ python = [ "3.13" ] # set the environment variable `UV_PRERELEASE` to "allow". matrix.deps.env-vars = [ { key = "UV_PRERELEASE", value = "allow", if = [ "pre" ] }, -] \ No newline at end of file +] + +[envs.notebooks] +extra-dependencies = [ + "ipykernel", + "jupytext", + "nbconvert", + "leidenalg", + "watermark", + "napari-spatialdata", +] +extras = ["docs"] + +[envs.notebooks.scripts] + +setup-squidpy-kernel = "python -m ipykernel install --user --name=squidpy" +run-notebooks = "python ./.scripts/ci/run_notebooks.py docs/notebooks" \ No newline at end of file From 7458bbbed05c67f10df0ce9c9e25b9a1dea5eea8 Mon Sep 17 00:00:00 2001 From: Marco Date: Sun, 26 Oct 2025 17:07:17 +0100 Subject: [PATCH 22/27] Change niche flavor to cellcharter_simple and default distance = 3 (#978) * Change niche flavor to cellcharter_simple and default distance = 3 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * added warning message --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Tim Treis Co-authored-by: Tim Treis --- src/squidpy/gr/_niche.py | 56 +++++++++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/src/squidpy/gr/_niche.py b/src/squidpy/gr/_niche.py index 13c7afef9..99717051b 100644 --- a/src/squidpy/gr/_niche.py +++ b/src/squidpy/gr/_niche.py @@ -45,6 +45,7 @@ def calculate_niche( n_components: int | None = None, random_state: int = 42, spatial_connectivities_key: str = "spatial_connectivities", + use_rep: str | None = None, inplace: bool = True, ) -> AnnData: """ @@ -58,7 +59,7 @@ def calculate_niche( Method to use for niche calculation. Available options are: - `{fla.NEIGHBORHOOD.s!r}` - cluster the neighborhood profile. - `{fla.UTAG.s!r}` - use utag algorithm (matrix multiplication). - - `{fla.CELLCHARTER.s!r}` - cluster adjacency matrix with Gaussian Mixture Model (GMM) using CellCharter's approach. + - `{fla.CELLCHARTER.s!r}` - a simplified version of CellCharter's approach, using PCA for dimensionality reduction. An arbitrary embedding can be used instead of PCA by setting the `use_rep` parameter which will try to find the embedding in `adata.obsm`. %(library_key)s If provided, niches will be calculated separately for each unique value in this column. Each niche will be prefixed with the library identifier. @@ -103,6 +104,9 @@ def calculate_niche( Optional if flavor == `{fla.CELLCHARTER.s!r}`. spatial_connectivities_key Key in `adata.obsp` where spatial connectivities are stored. + use_rep + Key in `adata.obsm` where the embedding is stored. If provided, this embedding will be used instead of PCA for dimensionality reduction. + Optional if flavor == `{fla.CELLCHARTER.s!r}`. inplace If 'True', perform the operation in place. If 'False', return a new AnnData object with the niche labels. @@ -111,6 +115,12 @@ def calculate_niche( if flavor == "cellcharter" and aggregation is None: aggregation = "mean" + if distance is None: + distance = 3 if flavor == "cellcharter" else 1 + + if flavor == "cellcharter" and n_components is None: + n_components = 10 + _validate_niche_args( data, flavor, @@ -127,15 +137,13 @@ def calculate_niche( aggregation, n_components, random_state, + use_rep, inplace, ) if resolutions is None: resolutions = [0.5] - if distance is None: - distance = 1 - if isinstance(data, SpatialData): orig_adata = data.tables[table_key] adata = orig_adata.copy() @@ -225,6 +233,7 @@ def calculate_niche( n_components, random_state, spatial_connectivities_key, + use_rep, ) if not inplace: @@ -293,6 +302,7 @@ def _calculate_niches( n_components: int | None, random_state: int, spatial_connectivities_key: str, + use_rep: str | None, ) -> None: """Calculate niches using the specified flavor and parameters.""" if flavor == "neighborhood": @@ -321,6 +331,7 @@ def _calculate_niches( n_components, random_state, spatial_connectivities_key, + use_rep, ) @@ -470,6 +481,7 @@ def _get_cellcharter_niches( n_components: int, random_state: int, spatial_connectivities_key: str, + use_rep: str | None = None, ) -> None: """adapted from https://github.com/CSOgroup/cellcharter/blob/main/src/cellcharter/gr/_aggr.py and https://github.com/CSOgroup/cellcharter/blob/main/src/cellcharter/tl/_gmm.py""" @@ -494,11 +506,32 @@ def _get_cellcharter_niches( concatenated_matrix = hstack(aggregated_matrices) # Stack all matrices horizontally arr = concatenated_matrix.toarray() # Densify - arr_ad = ad.AnnData(X=arr) - sc.tl.pca(arr_ad) + + if use_rep is not None: + # Use provided embedding from adata.obsm + if use_rep not in adata.obsm: + raise KeyError( + f"Embedding key '{use_rep}' not found in adata.obsm. Available keys: {list(adata.obsm.keys())}" + ) + embedding = adata.obsm[use_rep] + # Ensure embedding has the right number of components + if embedding.shape[1] < n_components: + raise ValueError( + f"Embedding has {embedding.shape[1]} components, but n_components={n_components}. Please provide an embedding with at least {n_components} components." + ) + # Use only the first n_components + embedding = embedding[:, :n_components] + else: + logg.warning( + "CellCharter recommends to use a dimensionality reduced embedding of the data, e.g. a scVI embedding. Since 'use_rep' is not provided, PCA will be used as proxy - performance may be suboptimal." + ) + + arr_ad = ad.AnnData(X=arr) + sc.tl.pca(arr_ad) + embedding = arr_ad.obsm["X_pca"] # cluster concatenated matrix with GMM, each cluster label equals to a niche label - niches = _get_GMM_clusters(arr_ad.obsm["X_pca"], n_components, random_state) + niches = _get_GMM_clusters(embedding, n_components, random_state) adata.obs["cellcharter_niche"] = pd.Categorical(niches) return @@ -681,6 +714,7 @@ def _validate_niche_args( aggregation: str | None, n_components: int | None, random_state: int, + use_rep: str | None, inplace: bool, ) -> None: """ @@ -761,8 +795,8 @@ def _validate_niche_args( ], }, "cellcharter": { - "required": ["distance", "aggregation", "n_components", "random_state"], - "optional": [], + "required": ["distance", "aggregation", "random_state"], + "optional": ["n_components", "use_rep"], "unused": [ "groups", "min_niche_size", @@ -794,6 +828,7 @@ def _validate_niche_args( "aggregation": aggregation, "n_components": n_components, "random_state": random_state, + "use_rep": use_rep, }, flavor_param_specs[flavor], ) @@ -828,6 +863,9 @@ def _validate_niche_args( if not isinstance(random_state, int): raise TypeError(f"'random_state' must be an integer, got {type(random_state).__name__}") + if use_rep is not None and not isinstance(use_rep, str): + raise TypeError(f"'use_rep' must be a string, got {type(use_rep).__name__}") + # for mypy if resolutions is None: resolutions = [0.0] From 8cf80011080b74ec81eb1ba5f8516d692c0add20 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Mon, 27 Oct 2025 16:05:10 +0100 Subject: [PATCH 23/27] Method to detect specimens in H&E images (#1044) * mvp for function; without testgs * added option to retain holes * refactor + 1 test * added missing import * renamed test so that a plot would be generated * added img from runner; cross-os-data-cache * improved docstring * added data download script to correct location * updated hatch commands * modified coverage combine * removed superflous combine step * first download data, then run tests * attempt to simplify * aligned testing * updated toml * aligned __init__ files * no uv cache for data download * removed download step that'd never get hit * simplify * parallel * speed up tests --------- Co-authored-by: Phil Schaf --- .github/workflows/test.yml | 66 ++- hatch.toml | 35 +- pyproject.toml | 55 +- src/squidpy/__init__.py | 4 +- src/squidpy/_utils.py | 26 + src/squidpy/experimental/__init__.py | 12 + src/squidpy/experimental/im/__init__.py | 9 + src/squidpy/experimental/im/_detect_tissue.py | 523 ++++++++++++++++++ src/squidpy/experimental/im/_utils.py | 137 +++++ .../DetectTissue_detect_tissue_otsu.png | Bin 0 -> 6849 bytes tests/experimental/__init__.py | 0 tests/experimental/test_detect_tissue.py | 24 + 12 files changed, 846 insertions(+), 45 deletions(-) create mode 100644 src/squidpy/experimental/__init__.py create mode 100644 src/squidpy/experimental/im/__init__.py create mode 100644 src/squidpy/experimental/im/_detect_tissue.py create mode 100644 src/squidpy/experimental/im/_utils.py create mode 100644 tests/_images/DetectTissue_detect_tissue_otsu.png create mode 100644 tests/experimental/__init__.py create mode 100644 tests/experimental/test_detect_tissue.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 061d30fc5..133ad6243 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,7 @@ name: CI on: schedule: - - cron: 00 00 * * 1 # every Monday at 00:00 + - cron: "00 00 * * 1" # every Monday at 00:00 push: branches: - main @@ -14,11 +14,42 @@ env: FORCE_COLOR: "1" MPLBACKEND: agg # It's impossible to ignore SyntaxWarnings for a single module, - # so because leidenalg 0.10.0 has them, we pre-compile things: https://github.com/vtraag/leidenalg/issues/173 + # so because leidenalg 0.10.0 has them, we pre-compile things: + # https://github.com/vtraag/leidenalg/issues/173 UV_COMPILE_BYTECODE: "1" + COVERAGE_FILE: ${{ github.workspace }}/.coverage jobs: + ensure-data-is-cached: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + filter: blob:none + + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: false + python-version: "3.13" + cache-dependency-glob: pyproject.toml + + - name: Restore data cache + id: data-cache + uses: actions/cache@v4 + with: + path: | + ~/.cache/squidpy/*.h5ad + ~/.cache/squidpy/*.zarr + key: data-${{ hashFiles('**/download_data.py') }} + enableCrossOsArchive: true + + - name: Download datasets + if: steps.data-cache.outputs.cache-hit != 'true' + run: uvx hatch run data:download + test: + needs: [ensure-data-is-cached] runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -34,16 +65,15 @@ jobs: os: ubuntu-latest python: "3.13" test-type: "coverage" + pytest-addopts: "-v --color=yes -n auto" - name: hatch-test.py3.13-pre os: macos-latest python: "3.13" - env: # environment variable for use in codecov's env_vars tagging + env: ENV_NAME: ${{ matrix.name }} steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - filter: blob:none + with: { fetch-depth: 0, filter: blob:none } - uses: astral-sh/setup-uv@v6 with: @@ -60,12 +90,9 @@ jobs: with: path: | ~/.cache/squidpy/*.h5ad + ~/.cache/squidpy/*.zarr key: data-${{ hashFiles('**/download_data.py') }} - - - name: Download datasets - if: steps.data-cache.outputs.cache-hit != 'true' - run: | - uvx hatch run ${{ matrix.name }}:download + enableCrossOsArchive: true - name: System dependencies (Linux) if: matrix.os == 'ubuntu-latest' @@ -81,18 +108,22 @@ jobs: if: matrix.os == 'macos-latest' run: brew install automake - - name: Install dependencies + - name: Create env run: uvx hatch -v env create ${{ matrix.name }} - name: Run tests if: matrix.test-type == null - run: uvx hatch run ${{ matrix.name }}:run + run: uvx hatch run ${{ matrix.name }}:run -n logical + - name: Run tests (coverage) if: matrix.test-type == 'coverage' + env: + PYTEST_ADDOPTS: ${{ matrix.pytest-addopts }} run: | - uvx hatch run ${{ matrix.name }}:run-cov - uvx hatch run ${{ matrix.name }}:coverage combine - uvx hatch run ${{ matrix.name }}:coverage xml + uvx hatch run ${{ matrix.name }}:cov-erase + uvx hatch run ${{ matrix.name }}:run-cov -n logical + uvx hatch run ${{ matrix.name }}:cov-combine + uvx hatch run ${{ matrix.name }}:cov-report - name: Archive figures generated during testing if: always() @@ -111,8 +142,7 @@ jobs: check: if: always() - needs: - - test + needs: [test] runs-on: ubuntu-latest steps: - uses: re-actors/alls-green@release/v1 diff --git a/hatch.toml b/hatch.toml index 388594bf6..cf07af8e9 100644 --- a/hatch.toml +++ b/hatch.toml @@ -2,24 +2,11 @@ installer = "uv" features = ["dev"] -[envs.coverage] -extra-dependencies = [ - "coverage[toml]", - "diff_cover", -] - -[envs.coverage.scripts] -clean = "coverage erase" -report = "coverage report --omit='tox/*'" -xml = "coverage xml --omit='tox/*' -o coverage.xml" -diff = "diff-cover --compare-branch origin/main coverage.xml" - [envs.docs] features = ["docs"] extra-dependencies = [ "setuptools", ] - [envs.docs.scripts] build = "make -C docs html {args}" clean = "make -C docs clean" @@ -32,14 +19,20 @@ download = "python ./.scripts/ci/download_data.py {args}" [envs.hatch-test] features = ["test"] -extra-dependencies = [ - "pytest", - "pytest-xdist", - "pytest-cov", - "pytest-mock", - "pytest-timeout", +extra-dependencies = ["diff-cover"] +[envs.hatch-test.scripts] +# defaults (only `cov-report` is overridden) +run = "pytest{env:HATCH_TEST_ARGS:} -p no:cov {args}" +run-cov = "coverage run -m pytest{env:HATCH_TEST_ARGS:} -p no:cov {args}" +cov-combine = ["coverage combine"] +cov-report = [ + "coverage report", + "coverage xml -o coverage.xml", + "diff-cover --compare-branch origin/main coverage.xml", ] - +# extra commands +cov-erase = "coverage erase" +download = "python ./.scripts/ci/download_data.py {args}" [[envs.hatch-test.matrix]] @@ -72,4 +65,4 @@ extras = ["docs"] [envs.notebooks.scripts] setup-squidpy-kernel = "python -m ipykernel install --user --name=squidpy" -run-notebooks = "python ./.scripts/ci/run_notebooks.py docs/notebooks" \ No newline at end of file +run-notebooks = "python ./.scripts/ci/run_notebooks.py docs/notebooks" diff --git a/pyproject.toml b/pyproject.toml index fde05acf3..cecbe86b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,16 +37,20 @@ authors = [ {name = "Giovanni Palla"}, {name = "Michal Klein"}, {name = "Hannah Spitzer"}, + {name = "Tim Treis"}, + {name = "Laurens Lehner"}, + {name = "Selman Ozleyen"}, ] maintainers = [ - {name = "Giovanni Palla", email = "giovanni.palla@helmholtz-muenchen.de"}, - {name = "Michal Klein", email = "michal.klein@helmholtz-muenchen.de"}, - {name = "Tim Treis", email = "tim.treis@helmholtz-muenchen.de"} + {name = "Tim Treis", email = "tim.treis@helmholtz-munich.de"}, + {name = "Selman Ozleyen", email = "selman.ozleyen@helmholtz-munich.de"} ] dependencies = [ "aiohttp>=3.8.1", "anndata>=0.9", + "spatialdata>=0.2.5", + "spatialdata-plot", "cycler>=0.11.0", "dask-image>=0.5.0", "dask[array]>=2021.02.0,<=2024.11.2", @@ -61,7 +65,7 @@ dependencies = [ "pandas>=2.1.0", "Pillow>=8.0.0", "scanpy>=1.9.3", - "scikit-image>=0.20", + "scikit-image>=0.25", # due to https://github.com/scikit-image/scikit-image/issues/6850 breaks rescale ufunc "scikit-learn>=0.24.0", "statsmodels>=0.12.0", @@ -70,14 +74,20 @@ dependencies = [ "tqdm>=4.50.2", "validators>=0.18.2", "xarray>=2024.10.0", + "imagecodecs>=2025.8.2,<2026", "zarr>=2.6.1", - "spatialdata>=0.2.5", ] [project.optional-dependencies] dev = [ "pre-commit>=3.0.0", "hatch>=1.9.0", + "jupyterlab", + "notebook", + "ipykernel", + "ipywidgets", + "jupytext", + "ruff", ] test = [ "scanpy[leiden]", @@ -262,6 +272,7 @@ omit = [ "*/__init__.py", "*/_version.py", "squidpy/pl/_interactive/*", + "tox/*", ] [tool.coverage.paths] @@ -282,3 +293,37 @@ show_missing = true precision = 2 skip_empty = true sort = "Miss" + +[tool.pixi.workspace] +channels = ["conda-forge"] +platforms = ["osx-arm64", "linux-64"] + +[tool.pixi.dependencies] +python = ">=3.11" + +[tool.pixi.pypi-dependencies] +squidpy = { path = ".", editable = true } + +[tool.pixi.feature.py311.dependencies] +python = "3.11.*" + +[tool.pixi.feature.py313.dependencies] +python = "3.13.*" + +[tool.pixi.environments] +dev-py311 = { features = ["dev", "test", "py311"], solve-group = "py311" } +docs-py311 = { features = ["docs", "py311"], solve-group = "py311" } + +default = { features = ["py313"], solve-group = "py313" } +dev-py313 = { features = ["dev", "test", "py313"], solve-group = "py313" } +docs-py313 = { features = ["docs", "py313"], solve-group = "py313" } +test-py313 = { features = ["test", "py313"], solve-group = "py313" } + +[tool.pixi.tasks] +lab = "jupyter lab" +kernel-install = "python -m ipykernel install --user --name pixi-dev --display-name \"squidpy (dev)\"" +test = "pytest -v --color=yes --tb=short --durations=10" +lint = "ruff check ." +format = "ruff format ." +pre-commit-install = "pre-commit install" +pre-commit = "pre-commit run" diff --git a/src/squidpy/__init__.py b/src/squidpy/__init__.py index d52a64cbd..d24e990de 100644 --- a/src/squidpy/__init__.py +++ b/src/squidpy/__init__.py @@ -3,7 +3,7 @@ from importlib import metadata from importlib.metadata import PackageMetadata -from squidpy import datasets, gr, im, pl, pp, read, tl +from squidpy import datasets, experimental, gr, im, pl, pp, read, tl try: md: PackageMetadata = metadata.metadata(__name__) @@ -14,3 +14,5 @@ md = None # type: ignore[assignment] del metadata, md + +__all__ = ["datasets", "experimental", "gr", "im", "pl", "pp", "read", "tl"] diff --git a/src/squidpy/_utils.py b/src/squidpy/_utils.py index 3d1f26b86..99f1b1348 100644 --- a/src/squidpy/_utils.py +++ b/src/squidpy/_utils.py @@ -16,6 +16,8 @@ import joblib as jl import numba import numpy as np +import spatialdata as sd +from spatialdata.models import Image2DModel, Labels2DModel __all__ = ["singledispatchmethod", "Signal", "SigQueue", "NDArray", "NDArrayA"] @@ -347,3 +349,27 @@ def new_func2(*args: Any, **kwargs: Any) -> Any: else: raise TypeError(repr(type(reason))) + + +def _get_scale_factors( + element: Image2DModel | Labels2DModel, +) -> list[float]: + """ + Get the scale factors of an image or labels. + """ + if not hasattr(element, "keys"): + return [] # element isn't a datatree -> single scale + + shapes = [_yx_from_shape(element[scale].image.shape) for scale in element.keys()] + + factors: list[float] = [(y0 / y1 + x0 / x1) / 2 for (y0, x0), (y1, x1) in zip(shapes, shapes[1:], strict=False)] + return [int(f) for f in factors] + + +def _yx_from_shape(shape: tuple[int, ...]) -> tuple[int, int]: + if len(shape) == 2: # (y, x) + return shape[0], shape[1] + if len(shape) == 3: # (c, y, x) + return shape[1], shape[2] + + raise ValueError(f"Unsupported shape {shape}. Expected (y, x) or (c, y, x).") diff --git a/src/squidpy/experimental/__init__.py b/src/squidpy/experimental/__init__.py new file mode 100644 index 000000000..df8681a40 --- /dev/null +++ b/src/squidpy/experimental/__init__.py @@ -0,0 +1,12 @@ +"""Experimental module for Squidpy. + +This module contains experimental features that are still under development. +These features may change or be removed in future releases. +""" + +from __future__ import annotations + +from . import im +from .im._detect_tissue import detect_tissue + +__all__ = ["detect_tissue", "im"] diff --git a/src/squidpy/experimental/im/__init__.py b/src/squidpy/experimental/im/__init__.py new file mode 100644 index 000000000..5c43a7b78 --- /dev/null +++ b/src/squidpy/experimental/im/__init__.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from ._detect_tissue import ( + BackgroundDetectionParams, + FelzenszwalbParams, + detect_tissue, +) + +__all__ = ["detect_tissue", "BackgroundDetectionParams", "FelzenszwalbParams"] diff --git a/src/squidpy/experimental/im/_detect_tissue.py b/src/squidpy/experimental/im/_detect_tissue.py new file mode 100644 index 000000000..3b0fa8a78 --- /dev/null +++ b/src/squidpy/experimental/im/_detect_tissue.py @@ -0,0 +1,523 @@ +from __future__ import annotations + +import enum +from dataclasses import dataclass +from typing import Literal + +import numpy as np +import spatialdata as sd +import xarray as xr +from dask_image.ndinterp import affine_transform as da_affine +from skimage import measure +from skimage.filters import gaussian, threshold_otsu +from skimage.morphology import binary_closing, disk, remove_small_holes +from skimage.segmentation import felzenszwalb +from skimage.util import img_as_float +from spatialdata._logging import logger as logg +from spatialdata.models import Labels2DModel +from spatialdata.transformations import get_transformation + +from squidpy._utils import _get_scale_factors, _yx_from_shape + +from ._utils import _flatten_channels, _get_image_data + + +class DETECT_TISSUE_METHOD(enum.Enum): + OTSU = enum.auto() + FELZENSZWALB = enum.auto() + + +@dataclass(slots=True) +class BackgroundDetectionParams: + """ + Which corners are background, and how large the corner boxes should be. + If no corners are flagged True, orientation falls back to bright background. + """ + + ymin_xmin_is_bg: bool = True + ymax_xmin_is_bg: bool = True + ymin_xmax_is_bg: bool = True + ymax_xmax_is_bg: bool = True + corner_size_pct: float = 0.01 # fraction of height/width + + @property + def any_corner(self) -> bool: + return any( + ( + self.ymin_xmin_is_bg, + self.ymax_xmin_is_bg, + self.ymin_xmax_is_bg, + self.ymax_xmax_is_bg, + ) + ) + + +@dataclass(slots=True) +class FelzenszwalbParams: + """ + Size-aware superpixel defaults for felzenszwalb segmentation. + """ + + grid_rows: int = 100 + grid_cols: int = 100 + sigma_frac: float = 0.008 # blur = this * short side, clipped to [1, 5] px + scale_coef: float = 0.25 # scale = coef * target_area + min_size_coef: float = 0.20 # min_size = coef * target_area + + +def detect_tissue( + sdata: sd.SpatialData, + image_key: str, + *, + scale: str = "auto", + method: DETECT_TISSUE_METHOD | str = DETECT_TISSUE_METHOD.OTSU, + channel_format: Literal["infer", "rgb", "rgba", "multichannel"] = "infer", + background_detection_params: BackgroundDetectionParams | None = None, + corners_are_background: bool = True, + min_specimen_area_frac: float = 0.01, + n_samples: int | None = None, + auto_max_pixels: int = 5_000_000, + close_holes_smaller_than_frac: float = 0.0001, + mask_smoothing_cycles: int = 0, + new_labels_key: str | None = None, + inplace: bool = True, + felzenszwalb_params: FelzenszwalbParams | None = None, +) -> np.ndarray | None: + """ + Detect tissue regions in an image and optionally store an integer-labeled mask. + + Parameters + ---------- + sdata + SpatialData object containing the image. + image_key + Key of the image in ``sdata.images`` to detect tissue from. + scale + Scale level to use for processing. If `"auto"`, uses the smallest available scale. + Otherwise, must be a valid scale level present in the image. + method + Tissue detection method. Valid options are: + + - `DETECT_TISSUE_METHOD.OTSU` or `"otsu"` - Otsu thresholding with background detection. + - `DETECT_TISSUE_METHOD.FELZENSZWALB` or `"felzenszwalb"` - Felzenszwalb superpixel segmentation. + + channel_format + Expected format of image channels. Valid options are: + + - `"infer"` - Automatically infer from image shape. + - `"rgb"` - RGB image. + - `"rgba"` - RGBA image. + - `"multichannel"` - Multi-channel image. + + background_detection_params + Parameters for background detection via corner regions. If `None`, uses corners + specified by `corners_are_background` for all four corners. + corners_are_background + Whether corners are considered background regions. Used for orienting threshold + if `background_detection_params` is `None`. + min_specimen_area_frac + Minimum fraction of image area for a region to be considered a specimen. + n_samples + Maximum number of specimen regions to keep. If `None`, uses Otsu thresholding + on log10(area) to separate specimens from artifacts. + auto_max_pixels + Maximum number of pixels to process automatically. Images larger than this + will be downscaled before processing. + close_holes_smaller_than_frac + Fraction of image area below which holes in the tissue mask are filled. + mask_smoothing_cycles + Number of morphological closing cycles to apply for boundary smoothing. + new_labels_key + Key to store the resulting labels in ``sdata.labels``. If `None`, uses + `"{image_key}_tissue"`. + inplace + If `True`, stores labels in ``sdata.labels``. If `False`, returns the mask array. + felzenszwalb_params + Parameters for Felzenszwalb superpixel segmentation. If `None`, uses default + size-aware parameters. Only used when `method` is `"felzenszwalb"`. + + Returns + ------- + If `inplace = False`, returns a NumPy array of shape `(y, x)` with integer labels + where `0` represents background and `1..K` represent different specimen regions. + Otherwise, returns `None` and stores the labels in ``sdata.labels``. + + Notes + ----- + The function produces an integer-labeled mask where: + + - Label `0` represents background. + - Labels `1..K` represent different specimen regions. + + Processing is performed at an appropriate resolution and then upscaled to match + the original image dimensions. + """ + # Normalize method + if isinstance(method, str): + try: + method = DETECT_TISSUE_METHOD[method.upper()] + except KeyError as e: + raise ValueError('method must be "otsu" or "felzenszwalb"') from e + + # Background params + bgp = background_detection_params or BackgroundDetectionParams( + ymin_xmin_is_bg=corners_are_background, + ymax_xmin_is_bg=corners_are_background, + ymin_xmax_is_bg=corners_are_background, + ymax_xmax_is_bg=corners_are_background, + ) + + manual_scale = scale.lower() != "auto" + + # Load smallest available or explicit scale + img_src = _get_image_data(sdata, image_key, scale=scale if manual_scale else "auto") + src_h, src_w = _yx_from_shape(img_src.shape) + n_src_px = src_h * src_w + + # Channel flattening + img_grey_da: xr.DataArray = _flatten_channels(img=img_src, channel_format=channel_format) + + # Decide working resolution + need_downscale = (not manual_scale) and (n_src_px > auto_max_pixels) + if need_downscale: + logg.info("Downscaling for faster computation.") + img_grey = _downscale_with_dask(img_grey=img_grey_da, target_pixels=auto_max_pixels) + else: + img_grey = img_grey_da.values # may compute + + # First-pass foreground + if method == DETECT_TISSUE_METHOD.OTSU: + img_fg_mask_bool = _segment_otsu(img_grey=img_grey, params=bgp) + else: + p = felzenszwalb_params or FelzenszwalbParams() + labels_sp = _segment_felzenszwalb(img_grey=img_grey, params=p) + img_fg_mask_bool = _mask_from_labels_via_corners(img_grey=img_grey, labels=labels_sp, params=bgp) + + # Solidify holes + if close_holes_smaller_than_frac > 0: + img_fg_mask_bool = _make_solid(img_fg_mask_bool, close_holes_smaller_than_frac) + + # Keep specimen-sized components → integer labels + img_fg_labels = _filter_by_area( + mask=img_fg_mask_bool, + min_specimen_area_frac=min_specimen_area_frac, + n_samples=n_samples, + ) + + # Optional smoothing → relabel once + img_fg_labels = _smooth_mask(img_fg_labels, mask_smoothing_cycles) + + # Upscale to full resolution + target_shape = _get_target_upscale_shape(sdata, image_key) + scale_matrix = _get_scaling_matrix(img_fg_labels.shape, target_shape) + img_fg_labels_up = _affine_upscale_nearest(img_fg_labels, scale_matrix, target_shape) + + if inplace: + lk = new_labels_key or f"{image_key}_tissue" + sf = _get_scale_factors(sdata.images[image_key]) + + sdata.labels[lk] = Labels2DModel.parse( + data=img_fg_labels_up, + dims=("y", "x"), + transformations=get_transformation(sdata.images[image_key], get_all=True), + scale_factors=sf, + ) + return None + + # If dask-backed, return a NumPy array to honor the signature + try: + import dask.array as da # noqa: F401 + + if hasattr(img_fg_labels_up, "compute"): + return np.asarray(img_fg_labels_up.compute()) + except (ImportError, AttributeError, TypeError): + pass + return np.asarray(img_fg_labels_up) + + +# Line 182 - convert dask array to numpy +def _affine_upscale_nearest(labels: np.ndarray, scale_matrix: np.ndarray, target_shape: tuple[int, int]) -> np.ndarray: + """ + Nearest-neighbor affine upscaling using dask-image. Returns dask array if available, else NumPy. + """ + try: + import dask.array as da + + lbl_da = da.from_array(labels, chunks="auto") + result = da_affine( + lbl_da, + matrix=scale_matrix, + offset=(0.0, 0.0), + output_shape=target_shape, + order=0, + mode="constant", + cval=0, + output=np.int32, + ) + + return np.asarray(result) + except (ImportError, AttributeError, TypeError): + sy = target_shape[0] / labels.shape[0] + sx = target_shape[1] / labels.shape[1] + yi = np.clip((np.arange(target_shape[0]) / sy).round().astype(int), 0, labels.shape[0] - 1) + xi = np.clip((np.arange(target_shape[1]) / sx).round().astype(int), 0, labels.shape[1] - 1) + return np.asarray(labels[yi[:, None], xi[None, :]].astype(np.int32, copy=False)) + + +def _get_scaling_matrix(current_shape: tuple[int, int], target_shape: tuple[int, int]) -> np.ndarray: + """ + Affine matrix mapping output coords to input coords for scipy/dask-image. + """ + cy, cx = current_shape + ty, tx = target_shape + scale_y = cy / float(ty) + scale_x = cx / float(tx) + return np.array([[scale_y, 0.0], [0.0, scale_x]], dtype=float) + + +def _get_target_upscale_shape(sdata: sd.SpatialData, image_key: str) -> tuple[int, int]: + """ + Select the first multiscale level (assumed largest) or the single-scale shape. + """ + img = sdata.images[image_key] + + # Image2DModel-like + if hasattr(img, "image"): + return _yx_from_shape(img.image.shape) + + # Multiscale dict-like: first key is largest by convention + if hasattr(img, "keys"): + keys = list(img.keys()) + target_scale = keys[0] + h, w = _yx_from_shape(img[target_scale].image.shape) + return (h, w) + + # Raw array fallback + return _yx_from_shape(img.shape) + + +def _downscale_with_dask(img_grey: xr.DataArray, target_pixels: int) -> np.ndarray: + """ + Downscale (y, x) with xarray.coarsen(mean) until H*W <= target_pixels. Returns NumPy array. + """ + h, w = img_grey.shape + n = h * w + if n <= target_pixels: + return _dask_compute(_ensure_dask(img_grey)) + + scale = float(np.sqrt(target_pixels / float(n))) # 0 < scale < 1 + target_h = max(1, int(h * scale)) + target_w = max(1, int(w * scale)) + + fy = max(1, int(np.ceil(h / target_h))) + fx = max(1, int(np.ceil(w / target_w))) + logg.info(f"Downscaling from {h}×{w} with coarsen={fy}×{fx} to ≤{target_pixels} px.") + + da_small = _ensure_dask(img_grey).coarsen(y=fy, x=fx, boundary="trim").mean() + return np.asarray(_dask_compute(da_small)) + + +def _ensure_dask(da: xr.DataArray) -> xr.DataArray: + """ + Ensure DataArray is dask-backed. If not, chunk to reasonable tiles. + """ + try: + import dask.array as dask_array # noqa: F401 + + if hasattr(da, "data") and isinstance(da.data, dask_array.Array): + return da + return da.chunk({"y": 2048, "x": 2048}) + except (ImportError, AttributeError): + return da + + +def _dask_compute(img_da: xr.DataArray) -> np.ndarray: + """ + Compute an xarray DataArray (possibly dask-backed) to a NumPy array with a ProgressBar if available. + """ + try: + import dask.array as dask_array # noqa: F401 + from dask.diagnostics import ProgressBar + + if hasattr(img_da, "data") and isinstance(img_da.data, dask_array.Array): + with ProgressBar(): + computed = img_da.data.compute() + return np.asarray(computed) + return np.asarray(img_da.values) + except (ImportError, AttributeError, TypeError): + return np.asarray(img_da.values) + + +def _segment_otsu(img_grey: np.ndarray, params: BackgroundDetectionParams) -> np.ndarray: + """ + Otsu binarization with orientation from background corners. + """ + img_f = img_as_float(img_grey) + t = threshold_otsu(img_f) + bright_bg = _background_is_bright(img_f, params) + return np.array((img_f <= t) if bright_bg else (img_f >= t)) + + +def _segment_felzenszwalb(img_grey: np.ndarray, params: FelzenszwalbParams) -> np.ndarray: + """ + Felzenszwalb superpixels with size-aware parameters. + """ + h, w = img_grey.shape + short = min(h, w) + sigma = float(np.clip(params.sigma_frac * short, 1.0, 5.0)) + img_s = img_as_float(gaussian(img_grey, sigma=sigma)) + + target_regions = max(1, params.grid_rows * params.grid_cols) + target_area = (h * w) / float(target_regions) + scale = float(max(1.0, params.scale_coef * target_area)) + min_size = int(max(1, params.min_size_coef * target_area)) + + return np.array( + felzenszwalb( + img_s, + scale=scale, + sigma=sigma, + min_size=min_size, + channel_axis=None, + ).astype(np.int32) + ) + + +def _mask_from_labels_via_corners( + img_grey: np.ndarray, labels: np.ndarray, params: BackgroundDetectionParams +) -> np.ndarray: + """ + Turn superpixels into a mask via Otsu on per-label mean intensity, oriented by corners. + """ + labels = labels.astype(np.int32, copy=False) + max_lab = int(labels.max()) + if max_lab <= 0: + return np.zeros_like(img_grey, dtype=bool) + + flat = labels.ravel() + imgf = img_as_float(img_grey).ravel() + + counts = np.bincount(flat, minlength=max_lab + 1).astype(np.float64) + sums = np.bincount(flat, weights=imgf, minlength=max_lab + 1) + means = np.zeros(max_lab + 1, dtype=np.float64) + nz = counts > 0 + means[nz] = sums[nz] / counts[nz] + + valid = means[1:] + if valid.size > 1: + thr = threshold_otsu(valid) + elif valid.size == 1: + thr = float(valid[0]) - 1.0 + else: + thr = 0.0 + + bright_bg = _background_is_bright(img_as_float(img_grey), params) + keep = (means <= thr) if bright_bg else (means >= thr) + keep[0] = False + return np.array(keep[labels], dtype=bool) + + +def _background_is_bright(img_grey: np.ndarray, params: BackgroundDetectionParams) -> bool: + """ + Decide if background is bright using flagged corners. + If none are flagged or mask ends up empty, return True. + """ + H, W = img_grey.shape + ch = max(1, int(params.corner_size_pct * H)) + cw = max(1, int(params.corner_size_pct * W)) + + if not params.any_corner: + return True + + corner_mask = np.zeros((H, W), dtype=bool) + if params.ymin_xmin_is_bg: + corner_mask[:ch, :cw] = True + if params.ymin_xmax_is_bg: + corner_mask[:ch, -cw:] = True + if params.ymax_xmin_is_bg: + corner_mask[-ch:, :cw] = True + if params.ymax_xmax_is_bg: + corner_mask[-ch:, -cw:] = True + + if not corner_mask.any(): + return True + + corner_mean = float(img_grey[corner_mask].mean()) + global_median = float(np.median(img_grey)) + return corner_mean >= global_median + + +def _make_solid(mask: np.ndarray, close_holes_smaller_than_frac: float = 0.01) -> np.ndarray: + """ + Fill holes smaller than the provided fraction of image area. + """ + if mask.dtype != bool: + mask = mask.astype(bool, copy=False) + + max_hole_area = int(close_holes_smaller_than_frac * mask.size) + return np.array(remove_small_holes(mask, area_threshold=max_hole_area)) + + +def _smooth_mask(mask: np.ndarray, cycles: int) -> np.ndarray: + """ + Apply morphological closing cycles to smooth boundaries, then relabel once. + """ + if cycles <= 0: + return mask.astype(np.int32, copy=False) + + binary = mask > 0 + H, W = mask.shape + r0 = max(1, min(5, min(H, W) // 100)) + + sm = binary + for i in range(cycles): + sm = binary_closing(sm, disk(r0 + i)) + + return np.asarray(measure.label(sm, connectivity=2).astype(np.int32, copy=False)) + + +def _filter_by_area( + mask: np.ndarray, + min_specimen_area_frac: float, + n_samples: int | None = None, +) -> np.ndarray: + """ + Keep specimen-sized connected components. Returns int32 labels. + If n_samples is given, keep top-n by area after min-area filtering. + Else, Otsu on log10(area) separates specimens from small artifacts. + """ + labels = measure.label(mask.astype(bool, copy=False), connectivity=2) + n = int(labels.max()) + if n == 0: + return np.zeros_like(labels, dtype=np.int32) + + areas = np.bincount(labels.ravel(), minlength=n + 1)[1:].astype(np.int64) + ids = np.arange(1, n + 1, dtype=np.int32) + + H, W = labels.shape + min_area = max(1, int(min_specimen_area_frac * H * W)) + big_enough = areas >= min_area + if not np.any(big_enough): + return np.zeros_like(labels, dtype=np.int32) + + areas_big = areas[big_enough] + ids_big = ids[big_enough] + + if n_samples is not None: + order = np.argsort(areas_big)[::-1] + keep = ids_big[order[:n_samples]] + out = np.zeros_like(labels, dtype=np.int32) + for new_id, old_id in enumerate(keep, 1): + out[labels == old_id] = new_id + return out + + la = np.log10(areas_big + 1e-9) + thr = threshold_otsu(la) if la.size > 1 else la.min() - 1.0 + keep_ids = ids_big[la > thr] + if keep_ids.size == 0: + return np.zeros_like(labels, dtype=np.int32) + + out = np.zeros_like(labels, dtype=np.int32) + for new_id, old_id in enumerate(keep_ids, 1): + out[labels == old_id] = new_id + return out diff --git a/src/squidpy/experimental/im/_utils.py b/src/squidpy/experimental/im/_utils.py new file mode 100644 index 000000000..8075ac3ca --- /dev/null +++ b/src/squidpy/experimental/im/_utils.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from typing import Literal + +import spatialdata as sd +import xarray as xr +from spatialdata._logging import logger as logg + + +def _get_image_data( + sdata: sd.SpatialData, + image_key: str, + scale: str, +) -> xr.DataArray: + """ + Extract image data from SpatialData object, handling both datatree and direct DataArray images. + + Parameters + ---------- + sdata : SpatialData + SpatialData object + image_key : str + Key in sdata.images + scale : str + Multiscale level, e.g. "scale0", or "auto" for the smallest available scale + + Returns + ------- + xr.DataArray + Image data in (c, y, x) format + """ + img_node = sdata.images[image_key] + + # Check if the image is a datatree (has multiple scales) or a direct DataArray + if hasattr(img_node, "keys"): + available_scales = list(img_node.keys()) + + if scale == "auto": + scale = available_scales[-1] + elif scale not in available_scales: + print(scale) + print(available_scales) + scale = available_scales[-1] + logg.warning(f"Scale '{scale}' not found, using available scale. Available scales: {available_scales}") + + img_da = img_node[scale].image + else: + # It's a direct DataArray (no scales) + img_da = img_node.image if hasattr(img_node, "image") else img_node + + return _ensure_cyx(img_da) + + +def _ensure_cyx(img_da: xr.DataArray) -> xr.DataArray: + """Ensure dims are (c, y, x). Adds a length-1 "c" if missing.""" + dims = list(img_da.dims) + if "y" not in dims or "x" not in dims: + raise ValueError(f'Expected dims to include "y" and "x". Found dims={dims}') + + # Handle case where dims are (c, y, x) - keep as is + if "c" in dims: + return img_da if dims[0] == "c" else img_da.transpose("c", "y", "x") + # If no "c" dimension, add one + return img_da.expand_dims({"c": [0]}).transpose("c", "y", "x") + + +def _flatten_channels( + img: xr.DataArray, + channel_format: Literal["infer", "rgb", "rgba", "multichannel"] = "infer", +) -> xr.DataArray: + """ + Takes an image of shape (c, y, x) and returns a 2D image of shape (y, x). + + Conversion logic: + - 1 channel: Returns greyscale (removes channel dimension) + - 3 channels + "rgb"/"infer": Uses RGB luminance formula + - 4 channels + "rgba": Uses RGB luminance formula (ignores alpha) + - 2 channels or 4+ channels + "infer": Automatically treated as multichannel + - "multichannel": Always uses mean across all channels + + The function is silent unless the channel_format is not "infer". + + Parameters + ---------- + img : xr.DataArray + Input image with shape (c, y, x) + channel_format : Literal["infer", "rgb", "rgba", "multichannel"] + How to interpret the channels: + - "infer": Automatically infer format based on number of channels + - "rgb": Force RGB treatment (requires exactly 3 channels) + - "rgba": Force RGBA treatment (requires exactly 4 channels) + - "multichannel": Force multichannel treatment (mean across all channels) + + Returns + ------- + xr.DataArray + Greyscale image with shape (y, x) + """ + n_channels = img.sizes["c"] + + # 1 channel: always return greyscale + if n_channels == 1: + return img.squeeze("c") + + # If user explicitly specifies multichannel, always use mean + if channel_format == "multichannel": + logg.info(f"Converting {n_channels}-channel image to greyscale using mean across all channels") + return img.mean(dim="c") + + # Handle explicit RGB specification + if channel_format == "rgb": + if n_channels != 3: + raise ValueError(f"Cannot treat {n_channels}-channel image as RGB (requires exactly 3 channels)") + logg.info("Converting RGB image to greyscale using luminance formula") + weights = xr.DataArray([0.299, 0.587, 0.114], dims=["c"], coords={"c": img.coords["c"]}) + return (img * weights).sum(dim="c") + + elif channel_format == "rgba": + if n_channels != 4: + raise ValueError(f"Cannot treat {n_channels}-channel image as RGBA (requires exactly 4 channels)") + logg.info("Converting RGBA image to greyscale using luminance formula (ignoring alpha)") + weights = xr.DataArray([0.299, 0.587, 0.114, 0.0], dims=["c"], coords={"c": img.coords["c"]}) + return (img * weights).sum(dim="c") + + elif channel_format == "infer": + if n_channels == 3: + # 3 channels + infer -> RGB luminance formula + weights = xr.DataArray([0.299, 0.587, 0.114], dims=["c"], coords={"c": img.coords["c"]}) + return (img * weights).sum(dim="c") + else: + # 2 channels or 4+ channels + infer -> multichannel + return img.mean(dim="c") + + else: + raise ValueError( + f"Invalid channel_format: {channel_format}. Must be one of 'infer', 'rgb', 'rgba', 'multichannel'." + ) diff --git a/tests/_images/DetectTissue_detect_tissue_otsu.png b/tests/_images/DetectTissue_detect_tissue_otsu.png new file mode 100644 index 0000000000000000000000000000000000000000..ace87d09c0f18b16ffe43aa7cb5533faede8cb71 GIT binary patch literal 6849 zcma)hcQjnl+x3i+=tfPV3u5%>HOPeM!l)Cy6JeCm2SbpU5JU?`iD;2Tj~YY^VM6ri zLiFA{@AZDa@B8Ch>s{;p!I?mcIoIp;k4*?XU8JzX_QGA1$*2t=u&t^x<{IoFNk z25^1d=OhH&{@)wING~T5?Q$+dU=&ghb%Zwvbc6T0fprZ3bb>%kLK-R$4gGN&nL!?g3l~>b zeX>E#+!^s06%}Di{smrJ=nZaeqKKqt;$Ot&s#rC@8h08OZd5rGZ2U6g=d>Ef2D z>#Amk%+&qj`-5MPI4niv6*iAi$b^$a#j9_+cjM%u+@6y=EPj1c7SZc^Pkh~`vM-=w zYj1KUJ1!osX>R@;R(^KFT-ePPX}zrR*gjVEN)= z#^cS6HU{fA$ETysy&|C}T1?E$;O)Rh(4n;nI+f{gpvP6Iynn+HF4Zi6SRG84cA-Z;Q`8 zw>jA2#O;1i6c-nRw*P&I@9ouf?8T8POEBeER*Ij6zNBJ>jUI%Ecc9Bnf%#NPskEQ)7yX6hQ4ne}`|R8E5HdgA}( zlSkoI+B+7|wdDQ6)pKOOJB-c2^?$JcXEQF_3Y35>o_8?hy#7*4fGTG&!Tjf?-D-v< zL%|*x2LxN2GgB_+t{DLW7hZZ1sM4rwS^i$C!xcCa|KY7_+N(~nu_+K5pMGuNX>nUJ zC(A@4#Ua66+Igx!cG_;h4`l+KjV&VTZfq~1`Wst}+Ry>b#`>4RX#awH-jaB91W;j3 zFzI~ON6nBUcEzv|pjId5QZ`^RoVop_-bIbY4eB7hp z-}X>WNjuk%IR_JS2g>%as=bxYkW_m+_mm#Z!w*IOBoxQF(}E*s95Pgkc^J-xcci1NU6Nj5J{*FWnt53Juxf zg>!zl+U#J4YiuMJhRzUi+1PMPyzLZEpc|iA>o8g5l;~F*8|I{MCg|?q`N2sn<2EiK zg`AG*Te9oVRW&p=zTWiSeGfVZ^0iKOQ=9QyAxH8}JF&9hW`JFh0T@u&P zlKkRgY|v)i%jV02(#X$!Ah_G($LgobJQ_N-F82cCq9NNm8JTHoZB;HBKS4VIcljNz zMAXk;-FWxt(vp3}m+(tljw_P3;{zmfwQ#>1l2!KBE#Y+mHDfHY-O-; zaBNINLxa>~d}U;6N=Jfe!|zwc=k=|u6C}oMqPl-_5}&7$SW%FIe!D(&|02+FL=U75 z?o?8@DPlTawkiP)7h)hq4}m}^zKIZ@=d*>+UpP<6sm-`WPTdusxHKera^DWElwmKH z*7uCETfIk{oAx3`V+sBF>C>nF$A4cUDjk1;3e5x=Kjh^_WMyTAjP|188zIkvMXBk% z6oyBKsqgwr7Qs)_pmd9=b8(crljI~uC!{+2c6VqEln%C;-VX6HbzTjtj$YB`HK4G3 z#k=uB)^eGSK)q4eX{S@UoA7ziZ4sZgKHZohP*Qt0$ZcgHe}8`;LKgW>F<7p_Z>ylV z_!;KpT-(f%Q!paM@$Ya$8H!vIA=CGq-6>_|py+ldeN3%J1+n3Ws|ynFIQaMd_?X+lL1Sa$7sdP`ZZzW&!#PXp_krl{}pUh77&~w;Yq8UA; z7&J9K0s0-JEjQgKXiT1P5;kbp)x?AI?mx;J(#S+*=vEl<*X32>AMLaw7)9&1q>4~W-<(u;x`J@8`g1_r2 z6vw7@dQPeKZFH&K_j6_e(^7SiyysB*nWA%3f}{3tO;M2Gh#0V9hX$ z>cRF?W=+s8j1z^<+svz1F$6tCgQ&wA(XNW=qHPsJ%~G=P%B%)XzGvVp*$EUAOnJQ+ zvLMbS+o<+YtJUaI)O(BnW zr&p!Sl0`WO_UV_$jbU~~0kdAn=JXnL3A`5p5j4yossr+Qf_Zsmi(^l>nca1pNWuT3LN-x_9wRpG{1 z>A!$Dz3XMrE2%CGPoLI z^9ElbtrS797%7RXt82jW`qiGO>{(HTAOf`>ikuq4)^CtEc!*?BmMOQ)sO~Z`t<1i{z1)`w_R*7$gj=Lq6v`tIHI< zH6HC(va1r8>LG*MGZ+ZaG6t}U{d&gw|NQAHv1m}|rX{3>9FB?QClP*5|7ghXI$TBm zWdgrWhWjw8kv-&c^+Y6P@f$c$xL=M^jSbEU$0(il$Ned@AuMe?WE!>bW1Ru8;U+i7 z^JmZIfREDTjDpr3SO&zWMY<;n4<9`uoVnQaBS)lyuPK9LL@%GRYz#=H1Cv(qo28Zu zzF&=h!L0OnO+t^Zx6EPOC<~$&=<^D@>a3fKTTX6(}Ro)U+uX^TeL1mHMMF?-@ooLv3Y}A@6 zV;A=gNKypjVw#NzML^3#=xa(o&K=M6S^rY5rw`AzIO3q#0i;qb@<&iPwlr=!4rgZs zeFlczGp&2^WAe>+^>^G)Wb7u*h6s{UR1WG_<(XYzL6zbZTB<_IUnX+qgM;P`Y(*rt!D26Om84YS1qqvqVe`R(fON{*vAOjp`SU(Dw+JekNjq}@?n7K^pczw&j z%1Y@u`C+O^7&p7!V3YCN)>7PwE3ReNviOZ3;1!Cxw{G&+AQ_|}~$`q{s2FJ0W- z7Xj|=1W1#%YU@LX9`s6n>ha2x(5GcsTx>p~YU7Q(9IV)aL^APUij+c@6ww^=V8bnuM zvC`ACfaX~r|LQt4CPt1WOu3DFrAG_{uf*HMKdQ#$=jQ|I2`=;a3u8lXx7Z-=_azHe z##d`U65Y;wi{O1X^^=U%z>*ezm1r);g-a}=38-}F&5(`*C=4h)Bcl!|e^CxC>pdv} zyZ?TI$HXjfVGf#tFRL(*$hJ?{N;~gUtgfwTaMPlf8Wty0@UI!H3{^e57~5r$b{pegFD#q4TgfjhJ8eZRgAay7M(;hz!UJl{ z%gg&iE5`rzqOb~ec#@1G@gL5bgdmDvuq{t@rTP0+R4@Fs#%cbJV~sH%6YIGi>C1rm z#CdZw#89E=>G>GCW?j`a(ei;3&|bJ6QNBqOgi7{cO`Srcfsl5L{u-J^il%eF zSnBG%AZvZ_CYhIeQ*k?h#L(Z3`l)3VK@}=`h0H@8ElS<8bJBGy$OhP8`jHJJ4~Srq z6?beV8myp>V5XEH4jV`WW_gGsfv>l#80e?%m$oT4%Ry4g;p@{y)4B6r{W^OxgJ^wc zqUIq4t#rZ1w!J?|^pXgOpv_V-HSL`8n^IiuAD7ESsry|Xc2g(m24!@Sh@|oarp-JX0Rw1;!?~mT)jiUn12GVlYsqECjC05N@#N>?Iiy6qdgTR1) z32HxL1bQn3>yKjb5(e7pq#F%EES6}NL%!!Hn9w>2$c%>t7em=C?iF z&Dwb?IeB?Rxh=7Ug@wZQcR~p%DX-&gn`;eFvZio(JP&vh4KquOcTI2(rYwljHzPLI zL}VE?l+U{uZfQEHEK}2^FB5_t#P9l_pR9%LW(kyh{u~3C9Y+mP#^#<+Q=>;*%oR?Q z?H4<>HHI*-^a{>m@y42&r%*3kx#HoT%&clzBDhg0~yCq$V8~MoW znz?akm}WNDoT3$pY6FAQJ?xcW3M9>^3Cpax9WgA*s|d9PV{^YC9{WQfnT&b1#u0f5 z9{cc~I%s|APccEcL+)2KGQ6Bt!pn3P&bisk3t)m|O9dNZReeaKodH@efud%-3eNiWmBt07nrR(m(WnoBj49xc+EyNjY4vg$^$Imhqj~ zQqKdY!-AF_%OAh696YK(EHE(4k^H`|5mbbLo*>sCJSLmDLmg3d#jD#Frp)s&(XHl6 zp=^l1mLa-7_ad-@-i=Zyc)#a948-=(Ih#5;RNs$(o=|VS=AN0gQ6E!d_(T#RsWmSTjnFC zin*~`?@1Iw9s>EAWXaIIZYhJmZO^O9qpV6p#2Z+L(!axwTxr+VdQ4kTaiol$Cgj7T z99din#1`2?$GRdvKwA^CC>r?q!RhH8yz)VSeh~gBlz-%rULy6t+i+zFi}$E^7>xH1 zC!;IY((qX}b%~z4(SRo(Q>$-m%v@bv4Y^6N)v%@b>_1GF#qC#>;A?=VuE@r>t3SQ% zN#59*_ivXY**n*~8qJb#@{%NGFXx!(ky~a|@wxWNxr}gg082j~nBP(Djul-8vv9G~ zu&6!p?byd-04{vn||`PLLc1|CH?LuX4+H)f9*rO{>Jx_BoSl(TSdJ7Ki;KsyK1re;PB3KtFnfs+5gdG`(HV6WbOyp&iQo! z*T1*4qZ8EaX-#ewE68`VX!$&McXu4Gav2*XMuO?0i6RxkYeTO>4!35k%Kkg{VNc*G z{+%e-S^cb#wSxmsa&qz*VRU5f?}ah_MbpoAEl*S$YH`u>q@Lx;;pE%v9_$|+)<(Vn zcI*AnOZ3QRL>oXa-pTp!9~>N<&)!r5d9f@0`~CirHclqt^z`((OC6=JxmEm5mp~<%(-F%``=Q=7pHa^bZeB2>osh4(> znk~t1I>71h!0K}MR-Nz0Qx`;vd4+xSkc7~ZgC<|T1H!8pC%X#}y)G?QNu5N%Kla+0 z>wtH+G?$u>e$5e@wf?L4Sj)(WK{1;eWb{iV;R+yLU4Zp`0%)bN(n)hiUn@Xfef|1X z;cUHftgp{c>ZE2m@U^V>B1Z%;=1b-*o>U*9WkLTk5B56SwkS&NY{krLf^d1ZH8?o< z2=J+aT7L^Sc7eX0DQcy22qzJs*5+3~m9qIro&4PfwB+E>P&-c6d)HE}>xq9#C7(07 zjHz>+`R{>&);4M-e7dA7;QWs9o^%V8{sWw7S!(vVqchS-7}1M|!0sEXL0Ub8rCgWl zsHmv$AsRBeto$5;b@F-ee_Ua;i?kI~y3r?Nzz%u>iB_oyCD9rD1LWWQkJz4+49)x- z5!A8(N5$Ybmp=3*3FX@_^=3|bcCrFv((o-{;(6-$_ki;N?Q{v!>^_rtxz^bklbNr$ z;e&9s)(`&x<3AbDZny8hV&e-vZe#z~9!=+9VUhXy)2Bpg#p4_xxk|1Zdb4gRFA468 zLUsN4@mLl)t#AU&0P0_PkE0|PGhpmkATlPql1 z%m^>_Ws78^cYl6MI(cn%4pZF2i^Dbf{`;F@Phe#$zI`(nw)|!)B`r;T+~j3p;>S7O mVLTb>e0|>XAB%h8iqMPaz3hXF2N}R=5=cW;SEU4I9sXZwk4m}# literal 0 HcmV?d00001 diff --git a/tests/experimental/__init__.py b/tests/experimental/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/experimental/test_detect_tissue.py b/tests/experimental/test_detect_tissue.py new file mode 100644 index 000000000..7a54d41c5 --- /dev/null +++ b/tests/experimental/test_detect_tissue.py @@ -0,0 +1,24 @@ +"""Test for experimental tissue detection.""" + +from __future__ import annotations + +import spatialdata_plot as sdp + +import squidpy as sq +from tests.conftest import PlotTester, PlotTesterMeta + +_ = sdp + + +class TestDetectTissue(PlotTester, metaclass=PlotTesterMeta): + def test_plot_detect_tissue_otsu(self): + """Test OTSU tissue detection on Visium H&E dataset.""" + sdata = sq.datasets.visium_hne_sdata() + + sq.experimental.im.detect_tissue( + sdata, + image_key="hne", + method="otsu", + ) + + sdata.pl.render_labels("hne_tissue").pl.show() From 51c410c06bf7ef5fddc75c740cd9273b9dae0829 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 27 Oct 2025 17:05:21 +0100 Subject: [PATCH 24/27] [pre-commit.ci] pre-commit autoupdate (#997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.7.2 → v0.7.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.7.2...v0.7.3) * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.8.1 → v0.9.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.1...v0.9.1) - [github.com/pre-commit/mirrors-mypy: v1.13.0 → v1.14.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.13.0...v1.14.1) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fixed mypy * copied upper limited for dask from sdata * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.9.3 → v0.9.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.3...v0.9.6) - [github.com/pre-commit/mirrors-mypy: v1.14.1 → v1.15.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.14.1...v1.15.0) * mypy fixes * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/rbubley/mirrors-prettier: v3.5.3 → v3.6.2](https://github.com/rbubley/mirrors-prettier/compare/v3.5.3...v3.6.2) - [github.com/astral-sh/ruff-pre-commit: v0.11.7 → v0.14.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.7...v0.14.1) - [github.com/pre-commit/mirrors-mypy: v1.15.0 → v1.18.2](https://github.com/pre-commit/mirrors-mypy/compare/v1.15.0...v1.18.2) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * mypy fix * mypy fix --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Tim Treis Co-authored-by: Tim Treis Co-authored-by: Tim Treis --- .pre-commit-config.yaml | 14 +++++++++----- src/squidpy/pl/_interactive/_model.py | 2 +- src/squidpy/pl/_spatial_utils.py | 18 ++++++++++++------ 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8b3406a2a..b0fa2abbe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,20 +7,24 @@ default_stages: minimum_pre_commit_version: 2.16.0 repos: - repo: https://github.com/rbubley/mirrors-prettier - rev: v3.5.3 + rev: v3.6.2 hooks: - id: prettier - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.7 + rev: v0.14.1 hooks: - id: ruff types_or: [python, pyi, jupyter] args: [--fix, --exit-non-zero-on-fix] - id: ruff-format types_or: [python, pyi, jupyter] + - repo: https://github.com/asottile/blacken-docs + rev: 1.19.1 + hooks: + - id: blacken-docs - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.15.0 + rev: v1.18.2 hooks: - id: mypy - additional_dependencies: [numpy, types-requests] - exclude: tests/|docs/ + additional_dependencies: [numpy, pandas, types-requests] + exclude: tests/|docs/|.scripts/ci/download_data.py|squidpy/datasets/_(dataset|image).py diff --git a/src/squidpy/pl/_interactive/_model.py b/src/squidpy/pl/_interactive/_model.py index 4856dc091..a05b2bd06 100644 --- a/src/squidpy/pl/_interactive/_model.py +++ b/src/squidpy/pl/_interactive/_model.py @@ -102,7 +102,7 @@ def _set_library(self) -> None: self.library_id = [self.library_id] self.library_id, _ = _unique_order_preserving(self.library_id) # type: ignore[assignment] - if not len(self.library_id): + if self.library_id is None or not len(self.library_id): raise ValueError("No library ids have been selected.") # invalid library ids from adata are filtered below # invalid library ids from container raise KeyError in `__post_init__` after this call diff --git a/src/squidpy/pl/_spatial_utils.py b/src/squidpy/pl/_spatial_utils.py index 89859ff9a..9da0e1ad5 100644 --- a/src/squidpy/pl/_spatial_utils.py +++ b/src/squidpy/pl/_spatial_utils.py @@ -6,7 +6,7 @@ from functools import partial from numbers import Number from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Literal, NamedTuple, Optional, TypeAlias, Union +from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypeAlias, cast import dask.array as da import numpy as np @@ -842,12 +842,18 @@ def _prepare_params_plot( fig, ax = plt.subplots(figsize=figsize, dpi=dpi, constrained_layout=True) # set cmap and norm + if cmap is None: - cmap = plt.rcParams["image.cmap"] - if isinstance(cmap, str): - cmap = plt.colormaps[cmap] - cmap.set_bad("lightgray" if na_color is None else na_color) + cmap_name: str = str(plt.rcParams["image.cmap"]) + cmap_obj = plt.get_cmap(cmap_name) + elif isinstance(cmap, str): + cmap_obj = plt.get_cmap(cmap) + else: + cmap_obj = cmap # already a Colormap + + cmap_obj.set_bad("lightgray" if na_color is None else na_color) + # build norm as before... if isinstance(norm, Normalize): pass elif vcenter is None: @@ -863,7 +869,7 @@ def _prepare_params_plot( scalebar_dx, scalebar_units = _get_scalebar(scalebar_dx, scalebar_units, len(spatial_params.library_id)) fig_params = FigParams(fig, ax, axs, iter_panels, title, ax_labels, frameon) - cmap_params = CmapParams(cmap, img_cmap, norm) + cmap_params = CmapParams(cmap_obj, img_cmap, norm) scalebar_params = ScalebarParams(scalebar_dx, scalebar_units) return fig_params, cmap_params, scalebar_params, kwargs From 4b4b9c10500975f7a44fb6288825a13901d4db85 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 29 Oct 2025 15:28:07 +0100 Subject: [PATCH 25/27] [pre-commit.ci] pre-commit autoupdate (#1050) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.14.1 → v0.14.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.14.1...v0.14.2) - [github.com/asottile/blacken-docs: 1.19.1 → 1.20.0](https://github.com/asottile/blacken-docs/compare/1.19.1...1.20.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b0fa2abbe..7d9cbf03b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: hooks: - id: prettier - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.1 + rev: v0.14.2 hooks: - id: ruff types_or: [python, pyi, jupyter] @@ -19,7 +19,7 @@ repos: - id: ruff-format types_or: [python, pyi, jupyter] - repo: https://github.com/asottile/blacken-docs - rev: 1.19.1 + rev: 1.20.0 hooks: - id: blacken-docs - repo: https://github.com/pre-commit/mirrors-mypy From 3add1f9bd649179b8200a6fd38413dfeb12edee0 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Mon, 16 Jun 2025 11:27:38 +0200 Subject: [PATCH 26/27] init --- src/squidpy/pp/_simple.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/squidpy/pp/_simple.py b/src/squidpy/pp/_simple.py index 90f1d2b09..77f5ed2fc 100644 --- a/src/squidpy/pp/_simple.py +++ b/src/squidpy/pp/_simple.py @@ -14,8 +14,10 @@ ) + + def filter_cells( - data: sd.SpatialData, + data: ad.AnnData | sd.SpatialData, tables: list[str] | str | None = None, min_counts: int | None = None, min_genes: int | None = None, From 5ccbf332b6425e2a2e8d7410c46a4369109a4a1a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Nov 2025 12:04:51 +0000 Subject: [PATCH 27/27] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/squidpy/pp/_simple.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/squidpy/pp/_simple.py b/src/squidpy/pp/_simple.py index 77f5ed2fc..6ae422e90 100644 --- a/src/squidpy/pp/_simple.py +++ b/src/squidpy/pp/_simple.py @@ -14,8 +14,6 @@ ) - - def filter_cells( data: ad.AnnData | sd.SpatialData, tables: list[str] | str | None = None,