Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 76 additions & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 </dev/null &

- name: create hatch environment
run: uvx hatch env create hatch-test.py3.14-pre

- name: run tests using hatch
env:
PLATFORM: ubuntu-latest
DISPLAY: :42
# No coverage upload: the stable `coverage` job above already covers these tests.
# `--continue-on-collection-errors` so the marked tests still run and report even
# though tests/experimental/plotting fails to import -- both halves of the picture
# in one place. This job is expected to be red whenever the stack is broken.
run: uvx hatch run hatch-test.py3.14-pre:run -v --color=yes -n auto -m spatialdata_plot --continue-on-collection-errors

- name: Archive figures generated during testing
if: always()
uses: actions/upload-artifact@v4
with:
name: visual_test_results_Linux_hatch-test.py3.14-pre_spatialdata-plot
path: ${{ github.workspace }}/tests/figures/*


# Check that all tests defined above pass. This makes it easy to set a single "required" test in branch
# protection instead of having to update it frequently. See https://github.com/re-actors/alls-green#why.
Expand All @@ -229,6 +303,7 @@ jobs:
- get-environments
- test
- coverage
- spatialdata-plot
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@release/v1
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ addopts = [
]
markers = [
"internet: tests that require internet",
"spatialdata_plot: tests that render through spatialdata-plot",
# the below markers are just there because we import from anndata.tests.helpers,
# which means pytest sees them despite us not using them.
"gpu: tests that require GPU",
Expand Down
58 changes: 50 additions & 8 deletions src/squidpy/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,71 @@
from importlib.metadata import version

from packaging.version import Version
from scanpy.plotting._tools.scatterplots import _add_categorical_legend as add_categorical_legend
from scanpy.plotting._tools.scatterplots import _panel_grid as panel_grid
from scanpy.plotting._utils import add_colors_for_categorical_sample_annotation

__all__ = [
# scanpy
"set_default_colors_for_categorical_obs",
"add_categorical_legend",
"panel_grid",
"add_colors_for_categorical_sample_annotation",
"default_palette",
"default_frameon",
"vector_friendly",
# anndata
"ArrayView",
"SparseCSCView",
"SparseCSRView",
]

# See https://github.com/scverse/squidpy/issues/1061 for more details.
# Scanpy 0.11.x-0.12.x renamed set_default_colors_for_categorical_obs to _set_default_colors_for_categorical_obs
# and then changed it back. Try underscore version first, fall back to non-underscore.
# Scanpy 1.13 moved the pre-v2 plotting internals under ``scanpy.plotting.legacy``.
# ``scanpy.plotting.__getattr__`` forwards attribute access there, but submodule
# imports such as ``scanpy.plotting.palettes`` are not covered by it.
try:
from scanpy.plotting._utils import _set_default_colors_for_categorical_obs as set_default_colors_for_categorical_obs
from scanpy.plotting.legacy._tools.scatterplots import _add_categorical_legend as add_categorical_legend
from scanpy.plotting.legacy._tools.scatterplots import _panel_grid as panel_grid
from scanpy.plotting.legacy._utils import (
add_colors_for_categorical_sample_annotation,
set_default_colors_for_categorical_obs,
)
from scanpy.plotting.legacy.palettes import default_102 as default_palette
except ImportError:
from scanpy.plotting._utils import set_default_colors_for_categorical_obs
from scanpy.plotting._tools.scatterplots import _add_categorical_legend as add_categorical_legend
from scanpy.plotting._tools.scatterplots import _panel_grid as panel_grid
from scanpy.plotting._utils import add_colors_for_categorical_sample_annotation
from scanpy.plotting.palettes import default_102 as default_palette

# See https://github.com/scverse/squidpy/issues/1061 for more details.
# Scanpy 0.11.x-0.12.x renamed set_default_colors_for_categorical_obs to _set_default_colors_for_categorical_obs
# and then changed it back. Try underscore version first, fall back to non-underscore.
try:
from scanpy.plotting._utils import (
_set_default_colors_for_categorical_obs as set_default_colors_for_categorical_obs,
)
except ImportError:
from scanpy.plotting._utils import set_default_colors_for_categorical_obs


# Scanpy 1.13 also moved these two plotting defaults off ``Settings``, where they were the
# private ``_frameon`` / ``_vector_friendly`` class attributes, and onto module-level
# globals in ``scanpy.plotting.legacy.mpl_settings``. ``scanpy.set_figure_params`` rebinds
# them in either layout, so they must be read at call time rather than imported once.
try:
from scanpy.plotting.legacy import mpl_settings as _mpl_settings

def default_frameon() -> 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")
Expand Down
2 changes: 1 addition & 1 deletion src/squidpy/im/_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 6 additions & 7 deletions src/squidpy/pl/_spatial_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Empty file.
17 changes: 17 additions & 0 deletions tests/experimental/plotting/conftest.py
Original file line number Diff line number Diff line change
@@ -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")
37 changes: 37 additions & 0 deletions tests/experimental/plotting/test_stain_normalize.py
Original file line number Diff line number Diff line change
@@ -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")
29 changes: 0 additions & 29 deletions tests/experimental/test_stain_normalize.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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")
3 changes: 3 additions & 0 deletions tests/experimental/test_tiling_qc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading