diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a38c81478..9b4f4b86c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -153,7 +153,16 @@ jobs: env: PLATFORM: ${{ matrix.os }} DISPLAY: :42 - run: uvx hatch run ${{ matrix.env.name }}:run -v --color=yes -n auto + # The stable environments run the spatialdata-plot tests as part of the suite. + # The pre-release one leaves them to the dedicated `spatialdata-plot` job below. + # Two mechanisms are needed: `-m` deselects after collection, which is too late + # for tests/experimental/plotting, whose modules import spatialdata-plot at module + # level and so fail during collection; `--ignore` keeps them from being imported + # at all. The marker then covers tests that only reach spatialdata-plot at call + # time, through squidpy's own lazy import. + run: | + uvx hatch run ${{ matrix.env.name }}:run -v --color=yes -n auto \ + ${{ contains(matrix.env.name, 'pre') && '--ignore=tests/experimental/plotting -m "not spatialdata_plot"' || '' }} - name: Archive figures generated during testing if: always() @@ -219,6 +228,71 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true + # Everything marked `spatialdata_plot` renders through `spatialdata-plot`, whose own + # pre-release compatibility is out of our hands -- its failures would mask squidpy's in + # the pre-release environment. So the pre-release run of those tests is carved out here: + # still visible (and still failing) on its own line, without turning the whole + # pre-release environment red. The stable environments keep running these tests as part + # of the suite in the `test` and `coverage` jobs above. + spatialdata-plot: + name: spatialdata-plot tests (PRE-RELEASE DEPENDENCIES) (ubuntu-latest) + needs: [ensure-data-is-cached] + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v5 + with: + filter: blob:none + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + python-version: "3.14" + cache-dependency-glob: pyproject.toml + + - name: Ensure figure directory exists + run: mkdir -p "$GITHUB_WORKSPACE/tests/figures" + + - name: Restore data cache + id: data-cache + uses: actions/cache@v4 + with: + path: data # IMPORTANT: this will fail if scanpy.settings.datasetdir default changes + key: data-${{ hashFiles('**/download_data.py') }} + restore-keys: | + data- + enableCrossOsArchive: true + + - name: System dependencies (Linux) + 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 bool: + return _mpl_settings.FRAMEON + + def vector_friendly() -> bool: + return _mpl_settings.VECTOR_FRIENDLY + +except ImportError: + from scanpy import settings as _sc_settings + + def default_frameon() -> bool: + return _sc_settings._frameon + + def vector_friendly() -> bool: + return _sc_settings._vector_friendly CAN_USE_SPARSE_ARRAY = Version(version("anndata")) >= Version("0.11.0rc1") diff --git a/src/squidpy/im/_container.py b/src/squidpy/im/_container.py index 7b439a0ec..1656c550d 100644 --- a/src/squidpy/im/_container.py +++ b/src/squidpy/im/_container.py @@ -19,10 +19,10 @@ from dask import delayed from matplotlib.colors import ListedColormap from scanpy import logging as logg -from scanpy.plotting.palettes import default_102 as default_palette from skimage.transform import rescale from skimage.util import img_as_float +from squidpy._compat import default_palette from squidpy._constants._constants import InferDimensions from squidpy._constants._pkg_constants import Key from squidpy._docs import d, inject_docs diff --git a/src/squidpy/pl/_spatial_utils.py b/src/squidpy/pl/_spatial_utils.py index 9cf5a30e8..d125ddc8b 100644 --- a/src/squidpy/pl/_spatial_utils.py +++ b/src/squidpy/pl/_spatial_utils.py @@ -29,13 +29,12 @@ from matplotlib_scalebar.scalebar import ScaleBar from pandas import CategoricalDtype from scanpy import logging as logg -from scanpy import settings as sc_settings from skimage.color import label2rgb from skimage.morphology import erosion, square from skimage.segmentation import find_boundaries from skimage.util import map_array -from squidpy._compat import add_categorical_legend +from squidpy._compat import add_categorical_legend, default_frameon, vector_friendly from squidpy._constants._constants import ScatterShape from squidpy._constants._pkg_constants import Key from squidpy._utils import NDArrayA @@ -569,7 +568,7 @@ def _plot_edges( ax=ax, **kwargs, ) - edge_collection.set_rasterized(sc_settings._vector_friendly) + edge_collection.set_rasterized(vector_friendly()) ax.add_collection(edge_collection) @@ -904,7 +903,7 @@ def _panel_grid( def _set_ax_title(fig_params: FigParams, count: int, value_to_plot: str | None = None) -> Axes: ax = fig_params.axs[count] if fig_params.axs is not None else fig_params.ax - if not (sc_settings._frameon if fig_params.frameon is None else fig_params.frameon): + if not (default_frameon() if fig_params.frameon is None else fig_params.frameon): ax.axis("off") if fig_params.title is None: @@ -959,7 +958,7 @@ def _plot_scatter( coords[:, 1], s=outline_params.bg_size, c=outline_params.bg_color, - rasterized=sc_settings._vector_friendly, + rasterized=vector_friendly(), cmap=cmap_params.cmap, norm=norm, **kwargs, @@ -970,7 +969,7 @@ def _plot_scatter( coords[:, 1], s=outline_params.gap_size, c=outline_params.gap_color, - rasterized=sc_settings._vector_friendly, + rasterized=vector_friendly(), cmap=cmap_params.cmap, norm=norm, **kwargs, @@ -981,7 +980,7 @@ def _plot_scatter( coords[:, 1], c=np.array(color_vector), s=size, - rasterized=sc_settings._vector_friendly, + rasterized=vector_friendly(), cmap=cmap_params.cmap, norm=norm, **kwargs, diff --git a/tests/experimental/plotting/__init__.py b/tests/experimental/plotting/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/experimental/plotting/conftest.py b/tests/experimental/plotting/conftest.py new file mode 100644 index 000000000..1a04471f5 --- /dev/null +++ b/tests/experimental/plotting/conftest.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from pathlib import Path + +HERE = Path(__file__).parent + + +def pytest_collection_modifyitems(items): + """Mark everything in this directory as requiring spatialdata-plot. + + Every module here imports ``spatialdata_plot`` at module level, so applying the + marker from the directory keeps it in one place and covers files added later. + The hook is session-wide even though this conftest is not, hence the path filter. + """ + for item in items: + if item.path.is_relative_to(HERE): + item.add_marker("spatialdata_plot") diff --git a/tests/experimental/test_detect_tissue.py b/tests/experimental/plotting/test_detect_tissue.py similarity index 100% rename from tests/experimental/test_detect_tissue.py rename to tests/experimental/plotting/test_detect_tissue.py diff --git a/tests/experimental/test_make_tiles.py b/tests/experimental/plotting/test_make_tiles.py similarity index 100% rename from tests/experimental/test_make_tiles.py rename to tests/experimental/plotting/test_make_tiles.py diff --git a/tests/experimental/test_qc_image.py b/tests/experimental/plotting/test_qc_image.py similarity index 100% rename from tests/experimental/test_qc_image.py rename to tests/experimental/plotting/test_qc_image.py diff --git a/tests/experimental/plotting/test_stain_normalize.py b/tests/experimental/plotting/test_stain_normalize.py new file mode 100644 index 000000000..40f730c29 --- /dev/null +++ b/tests/experimental/plotting/test_stain_normalize.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import matplotlib.pyplot as plt +import spatialdata_plot as sdp +import xarray as xr +from spatialdata.models import Image2DModel + +import squidpy as sq +from squidpy.experimental.im import fit_stain_reference, normalize_stains +from squidpy.experimental.im._utils import get_element_data +from tests.conftest import PlotTester, PlotTesterMeta + +_ = sdp # registers the `.pl` spatialdata accessor + + +class TestStainNormalizationVisual(PlotTester, metaclass=PlotTesterMeta): + def test_plot_reinhard_before_after(self, sdata_hne) -> None: + """Visual: a re-stained source (left) normalized back to the H&E reference (right).""" + image_key = next(iter(sdata_hne.images)) + sq.experimental.im.detect_tissue(sdata_hne, image_key) + reference = fit_stain_reference(sdata_hne, image_key, method="reinhard") + + # Deterministically warm/cool the channels to simulate a different + # staining batch, so the before/after panels are visibly distinct. + da_rgb = get_element_data(sdata_hne.images[image_key], "auto", "image", image_key).astype("float32") + weights = xr.DataArray([1.4, 1.0, 0.6], dims="c", coords={"c": da_rgb.coords["c"]}) + shifted = (da_rgb * weights).clip(0, 255).astype("uint8") + sdata_hne.images["hne_shifted"] = Image2DModel.parse(shifted.data, dims=shifted.dims) + + # `hne_shifted` shares geometry with `image_key`; reuse its tissue mask. + normalize_stains( + sdata_hne, "hne_shifted", reference, image_key_added="hne_normalized", tissue_mask_key=f"{image_key}_tissue" + ) + + _, axes = plt.subplots(1, 2, figsize=(8, 4)) + sdata_hne.pl.render_images("hne_shifted").pl.show(ax=axes[0], title="before") + sdata_hne.pl.render_images("hne_normalized").pl.show(ax=axes[1], title="after") diff --git a/tests/experimental/test_stain_normalize.py b/tests/experimental/test_stain_normalize.py index e09c910ba..11156bd35 100644 --- a/tests/experimental/test_stain_normalize.py +++ b/tests/experimental/test_stain_normalize.py @@ -1,11 +1,9 @@ from __future__ import annotations import dask.array as da -import matplotlib.pyplot as plt import numpy as np import pytest import spatialdata as sd -import spatialdata_plot as sdp import xarray as xr from spatialdata.models import Image2DModel, Labels2DModel from spatialdata.transformations import Scale, get_transformation, set_transformation @@ -18,9 +16,6 @@ normalize_stains, ) from squidpy.experimental.im._utils import get_element_data -from tests.conftest import PlotTester, PlotTesterMeta - -_ = sdp # registers the `.pl` spatialdata accessor def _make_sdata( @@ -227,27 +222,3 @@ def test_fit_apply_smoke(self, sdata_hne) -> None: out = sq.experimental.im.normalize_stains(sdata_hne, image_key, ref, inplace=False) assert "c" in out.dims assert out.sizes["c"] == 3 - - -class TestStainNormalizationVisual(PlotTester, metaclass=PlotTesterMeta): - def test_plot_reinhard_before_after(self, sdata_hne) -> None: - """Visual: a re-stained source (left) normalized back to the H&E reference (right).""" - image_key = next(iter(sdata_hne.images)) - sq.experimental.im.detect_tissue(sdata_hne, image_key) - reference = fit_stain_reference(sdata_hne, image_key, method="reinhard") - - # Deterministically warm/cool the channels to simulate a different - # staining batch, so the before/after panels are visibly distinct. - da_rgb = get_element_data(sdata_hne.images[image_key], "auto", "image", image_key).astype("float32") - weights = xr.DataArray([1.4, 1.0, 0.6], dims="c", coords={"c": da_rgb.coords["c"]}) - shifted = (da_rgb * weights).clip(0, 255).astype("uint8") - sdata_hne.images["hne_shifted"] = Image2DModel.parse(shifted.data, dims=shifted.dims) - - # `hne_shifted` shares geometry with `image_key`; reuse its tissue mask. - normalize_stains( - sdata_hne, "hne_shifted", reference, image_key_added="hne_normalized", tissue_mask_key=f"{image_key}_tissue" - ) - - _, axes = plt.subplots(1, 2, figsize=(8, 4)) - sdata_hne.pl.render_images("hne_shifted").pl.show(ax=axes[0], title="before") - sdata_hne.pl.render_images("hne_normalized").pl.show(ax=axes[1], title="after") diff --git a/tests/experimental/test_tiling_qc.py b/tests/experimental/test_tiling_qc.py index b0ec4adcb..93f0b6a58 100644 --- a/tests/experimental/test_tiling_qc.py +++ b/tests/experimental/test_tiling_qc.py @@ -366,6 +366,9 @@ def sdata_with_qc(sdata_tile_boundary): return sdata +# `sq.experimental.pl.tiling_qc` imports spatialdata-plot lazily, so this module +# imports cleanly and only these tests need excluding when that stack is broken. +@pytest.mark.spatialdata_plot class TestTilingQCVisual(PlotTester, metaclass=PlotTesterMeta): def test_plot_tiling_qc_cut_score(self, sdata_with_qc): """Visual: labels coloured by cut_score."""