diff --git a/README.md b/README.md index 46ba6a58..0a8c3066 100644 --- a/README.md +++ b/README.md @@ -36,18 +36,16 @@ conda install -c conda-forge squidpy ### Interactive visualization -To get optional dependencies required for the napari-based interactive plotting APIs, install the `interactive` extra: - -```console -pip install 'squidpy[interactive]' -``` +For interactive visualization with napari, please use [napari-spatialdata](https://github.com/scverse/napari-spatialdata). +The original napari plugin from Squidpy has been deprecated and replaced by napari-spatialdata, which offers +improved functionality and support for the SpatialData ecosystem. ## Key capabilities - Build and analyze spatial neighbor graphs directly from Visium, Slide-seq, Xenium, and other spatial omics assays. - Compute spatial statistics for cell types and genes, including neighborhood enrichment, co-occurrence, and Moran's I. - Efficiently store, featurize, and visualize high-resolution tissue microscopy images via [scikit-image](https://scikit-image.org/). -- Explore annotated datasets interactively with [napari](https://napari.org/) and scverse visualization tooling. +- Explore annotated datasets interactively with [napari-spatialdata](https://github.com/scverse/napari-spatialdata). ## Contributing diff --git a/docs/index.md b/docs/index.md index e061a02a..e44bc299 100644 --- a/docs/index.md +++ b/docs/index.md @@ -44,7 +44,7 @@ Please see our manuscript [Palla, Spitzer et al. (2022)](https://doi.org/10.1038 - 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 [scikit-image](https://scikit-image.org/) -- Interactively explore [anndata](https://anndata.readthedocs.io/en/stable/) and large tissue images in [napari](https://napari.org/) +- Interactively explore spatial data with [napari-spatialdata](https://github.com/scverse/napari-spatialdata) ## Getting started with Squidpy diff --git a/docs/installation.md b/docs/installation.md index 5eec1466..c40f990d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -8,10 +8,6 @@ Install Squidpy by running:: pip install squidpy -Alternatively, to include all dependencies, such as the interactive image viewer {mod}`napari`, run:: - - pip install 'squidpy[interactive]' - ## Conda Install Squidpy via Conda as:: @@ -23,3 +19,13 @@ Install Squidpy via Conda as:: To install Squidpy from GitHub, run:: pip install git+https://github.com/scverse/squidpy@main + +## Interactive visualization + +For interactive visualization with napari, please use [napari-spatialdata](https://github.com/scverse/napari-spatialdata). +The original napari plugin from Squidpy has been deprecated and replaced by napari-spatialdata, +which offers improved functionality and support for the SpatialData ecosystem. + +Install napari-spatialdata with:: + + pip install napari-spatialdata diff --git a/docs/notebooks b/docs/notebooks index 1cbaf62a..510b92da 160000 --- a/docs/notebooks +++ b/docs/notebooks @@ -1 +1 @@ -Subproject commit 1cbaf62a32f65b950552229d210b9884757ce116 +Subproject commit 510b92da918efbb8b38cf7d5c3989b8e3ed19618 diff --git a/pyproject.toml b/pyproject.toml index d682d786..fb0b0215 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -219,8 +219,6 @@ lint.per-file-ignores.".scripts/ci/download_data.py" = [ "B", "D" ] lint.per-file-ignores."docs/*" = [ "B", "D" ] lint.per-file-ignores."src/squidpy/_constants/_constants.py" = [ "D101" ] lint.per-file-ignores."src/squidpy/_constants/_pkg_constants.py" = [ "D101", "D102", "D106" ] -lint.per-file-ignores."src/squidpy/pl/_interactive/_widgets.py" = [ "D" ] -lint.per-file-ignores."src/squidpy/pl/_interactive/interactive.py" = [ "E", "F" ] lint.per-file-ignores."src/squidpy/pl/_ligrec.py" = [ "B", "D" ] lint.per-file-ignores."tests/*" = [ "D" ] lint.unfixable = [ @@ -244,7 +242,6 @@ python_files = "test_*.py" testpaths = [ "tests/" ] xfail_strict = true addopts = [ - "--ignore=tests/plotting/test_interactive.py", "--ignore=docs", ] @@ -255,7 +252,6 @@ source = [ "squidpy" ] omit = [ "*/__init__.py", "*/_version.py", - "squidpy/pl/_interactive/*", "tox/*", ] diff --git a/src/squidpy/im/_container.py b/src/squidpy/im/_container.py index 8e8ac536..9615effc 100644 --- a/src/squidpy/im/_container.py +++ b/src/squidpy/im/_container.py @@ -7,7 +7,7 @@ from itertools import chain from pathlib import Path from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypeVar +from typing import TYPE_CHECKING, Any, Literal, TypeAlias import dask.array as da import matplotlib as mpl @@ -51,7 +51,6 @@ Arraylike_t: TypeAlias = NDArrayA | xr.DataArray InferDims_t: TypeAlias = Literal["default", "prefer_channels", "prefer_z"] | Sequence[str] Input_t: TypeAlias = Pathlike_t | Arraylike_t | Literal["ImageContainer"] -Interactive = TypeVar("Interactive") # cannot import because of cyclic dependencies _ERROR_NOTIMPLEMENTED_LIBID = f"It seems there are multiple `library_id` in `adata.uns[{Key.uns.spatial!r}]`.\n \ Loading multiple images is not implemented (yet), please specify a `library_id`." diff --git a/src/squidpy/pl/__init__.py b/src/squidpy/pl/__init__.py index 0bcc0023..9ddbf238 100644 --- a/src/squidpy/pl/__init__.py +++ b/src/squidpy/pl/__init__.py @@ -9,8 +9,6 @@ nhood_enrichment, ripley, ) - -# from squidpy.pl._interactive import Interactive # type: ignore[attr-defined] # deprecated from squidpy.pl._ligrec import ligrec from squidpy.pl._spatial import spatial_scatter, spatial_segment from squidpy.pl._utils import extract diff --git a/src/squidpy/pl/_interactive/__init__.py b/src/squidpy/pl/_interactive/__init__.py index 6e76150c..2df54885 100644 --- a/src/squidpy/pl/_interactive/__init__.py +++ b/src/squidpy/pl/_interactive/__init__.py @@ -1,3 +1,13 @@ +"""Deprecated napari interactive visualization module. + +This module has been deprecated in favor of napari-spatialdata. +Please see https://github.com/scverse/napari-spatialdata for the replacement. +""" + from __future__ import annotations -# from squidpy.pl._interactive.interactive import Interactive +raise ImportError( + "The squidpy napari plugin has been deprecated and removed. " + "Please use napari-spatialdata instead: " + "https://github.com/scverse/napari-spatialdata" +) diff --git a/src/squidpy/pl/_interactive/_controller.py b/src/squidpy/pl/_interactive/_controller.py deleted file mode 100644 index bbe8dcfe..00000000 --- a/src/squidpy/pl/_interactive/_controller.py +++ /dev/null @@ -1,346 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING, Any - -import numpy as np -import pandas as pd -import xarray as xr -from anndata import AnnData -from napari import Viewer -from napari.layers import Points, Shapes -from pandas import CategoricalDtype -from PyQt5.QtWidgets import QGridLayout, QLabel, QWidget -from scanpy import logging as logg - -from squidpy._docs import d -from squidpy._utils import NDArrayA, singledispatchmethod -from squidpy.im import ImageContainer -from squidpy.pl._interactive._model import ImageModel -from squidpy.pl._interactive._utils import ( - _display_channelwise, - _get_categorical, - _position_cluster_labels, -) -from squidpy.pl._interactive._view import ImageView -from squidpy.pl._interactive._widgets import RangeSlider # type: ignore[attr-defined] -from squidpy.pl._utils import _points_inside_triangles - -__all__ = ["ImageController"] - -# label string: attribute name -_WIDGETS_TO_HIDE = { - "symbol:": "symbolComboBox", - "point size:": "sizeSlider", - "face color:": "faceColorEdit", - "edge color:": "edgeColorEdit", - "out of slice:": "outOfSliceCheckBox", -} - - -@d.dedent -class ImageController: - """ - Controller class. - - Parameters - ---------- - %(adata)s - %(img_container)s - """ - - def __init__(self, adata: AnnData, img: ImageContainer, **kwargs: Any): - self._model = ImageModel(adata=adata, container=img, **kwargs) - self._view = ImageView(model=self.model, controller=self) - - self.view._init_UI() - - def add_image(self, layer: str) -> bool: - """ - Add a new :mod:`napari` image layer. - - Parameters - ---------- - layer - Layer in the underlying's :class:`ImageContainer` which contains the image. - - Returns - ------- - `True` if the layer has been added, otherwise `False`. - """ - if layer in self.view.layernames: - self._handle_already_present(layer) - return False - - if self.model.container.data[layer].attrs.get("segmentation", False): - return self.add_labels(layer) - - img: xr.DataArray = self.model.container.data[layer].transpose("z", "y", "x", ...) - multiscale = np.prod(img.shape[1:3]) > (2**16) ** 2 - n_channels = img.shape[-1] - - rgb = img.attrs.get("rgb", None) - if n_channels == 1: - rgb, colormap = False, "gray" - else: - colormap = self.model.cmap - - if rgb is None: - logg.debug("Automatically determining whether image is an RGB image") - rgb = not _display_channelwise(img.data) - - if rgb: - contrast_limits = None - else: - img = img.transpose(..., "z", "y", "x") # channels first - contrast_limits = float(img.min()), float(img.max()) - - logg.info(f"Creating image `{layer}` layer") - self.view.viewer.add_image( - img.data, - name=layer, - rgb=rgb, - colormap=colormap, - blending=self.model.blending, - multiscale=multiscale, - contrast_limits=contrast_limits, - ) - - return True - - def add_labels(self, layer: str) -> bool: - """ - Add a new :mod:`napari` labels layer. - - Parameters - ---------- - layer - Layer in the underlying's :class:`ImageContainer` which contains the labels image. - - Returns - ------- - `True` if the layer has been added, otherwise `False`. - """ - # beware `update_library` in view.py - needs to be in this order - img: xr.DataArray = self.model.container.data[layer].transpose(..., "z", "y", "x") - if img.ndim != 4: - logg.warning(f"Unable to show image of shape `{img.shape}`, too many dimensions") - return False - - if img.shape[0] != 1: - logg.warning(f"Unable to create labels layer of shape `{img.shape}`, too many channels `{img.shape[0]}`") - return False - - if not np.issubdtype(img.dtype, np.integer): - # could also return to `add_images` and render it as image - logg.warning(f"Expected label image to be a subtype of `numpy.integer`, found `{img.dtype}`") - return False - - logg.info(f"Creating label `{layer}` layer") - self.view.viewer.add_labels( - img.data, - name=layer, - multiscale=np.prod(img.shape[-2:]) > (2**16) ** 2, - ) - - return True - - def add_points(self, vec: NDArrayA | pd.Series, layer_name: str, key: str | None = None) -> bool: - """ - Add a new :mod:`napari` points layer. - - Parameters - ---------- - vec - Values to plot. If :class:`pandas.Series`, it is expected to be categorical. - layer_name - Name of the layer to add. - key - Key from :attr:`anndata.AnnData.obs` from where the data was taken from. - Only used when ``vec`` is :class:`pandas.Series`. - - Returns - ------- - `True` if the layer has been added, otherwise `False`. - """ - if layer_name in self.view.layernames: - self._handle_already_present(layer_name) - return False - - logg.info(f"Creating point `{layer_name}` layer") - properties = self._get_points_properties(vec, key=key) - layer: Points = self.view.viewer.add_points( - self.model.coordinates, - name=layer_name, - size=self.model.spot_diameter, - opacity=1, - blending=self.model.blending, - face_colormap=self.model.cmap, - edge_colormap=self.model.cmap, - symbol=self.model.symbol.v, - **properties, - ) - # TODO(michalk8): add contrasting fg/bg color once https://github.com/napari/napari/issues/2019 is done - self._hide_points_controls(layer, is_categorical=isinstance(vec.dtype, CategoricalDtype)) - layer.editable = False - - return True - - def export(self, _: Viewer) -> None: - """Export shapes into :class:`AnnData` object.""" - for layer in self.view.layers: - if not isinstance(layer, Shapes) or layer not in self.view.viewer.layers.selection: - continue - if not len(layer.data): - logg.warning(f"Shape layer `{layer.name}` has no visible shapes") - continue - - key = f"{layer.name}_{self.model.key_added}" - - logg.info(f"Adding `adata.obs[{key!r}]`\n `adata.uns[{key!r}]['meshes']`") - self._save_shapes(layer, key=key) - self._update_obs_items(key) - - def show(self, restore: bool = False) -> None: - """ - Launch the :class:`napari.Viewer`. - - Parameters - ---------- - restore - Whether to reinitialize the GUI after it has been destroyed. - - Returns - ------- - Nothing, just launches the viewer. - """ - try: - self.view.viewer.show() - except RuntimeError: - if restore: - self.view._init_UI() - self.view.viewer.show() - else: - logg.error("The viewer has already been closed. Try specifying `restore=True`") - - @d.get_full_description(base="cont_close") - def close(self) -> None: - """Close the :class:`napari.Viewer` or do nothing, if it's already closed.""" - try: - self.view.viewer.close() - except RuntimeError: - pass - - def screenshot(self, path: str | Path | None = None, canvas_only: bool = True) -> NDArrayA: - """ - Take a screenshot of the viewer's canvas. - - Parameters - ---------- - path - Path where to save the screenshot. If `None`, don't save it. - canvas_only - Whether to show only the canvas or also the widgets. - - Returns - ------- - Screenshot as an RGB array of shape ``(height, width, 3)``. - """ - return np.asarray(self.view.viewer.screenshot(path, canvas_only=canvas_only)) - - def _handle_already_present(self, layer_name: str) -> None: - logg.debug(f"Layer `{layer_name}` is already loaded") - self.view.viewer.layers.selection.select_only(self.view.layers[layer_name]) - - def _save_shapes(self, layer: Shapes, key: str) -> None: - shape_list = layer._data_view - triangles = shape_list._mesh.vertices[shape_list._mesh.displayed_triangles] - - # TODO(michalk8): account for current Z-dim? - points_mask: NDArrayA = _points_inside_triangles(self.model.coordinates[:, 1:], triangles) - - self.model.adata.obs[key] = pd.Categorical(points_mask) - self.model.adata.uns[key] = {"meshes": layer.data.copy()} - - def _update_obs_items(self, key: str) -> None: - self.view._obs_widget.addItems(key) - if key in self.view.layernames: - # update already present layer - layer = self.view.layers[key] - layer.face_color = _get_categorical(self.model.adata, key) - layer._update_thumbnail() - layer.refresh_colors() - - @singledispatchmethod - def _get_points_properties(self, vec: NDArrayA | pd.Series, **_: Any) -> dict[str, Any]: - raise NotImplementedError(type(vec)) - - @_get_points_properties.register(np.ndarray) - def _(self, vec: NDArrayA, **_: Any) -> dict[str, Any]: - return { - "text": None, - "face_color": "value", - "properties": {"value": vec}, - "metadata": {"perc": (0, 100), "data": vec, "minmax": (np.nanmin(vec), np.nanmax(vec))}, - } - - @_get_points_properties.register(pd.Series) - def _(self, vec: pd.Series, key: str) -> dict[str, Any]: - face_color = _get_categorical(self.model.adata, key=key, palette=self.model.palette, vec=vec) - return { - "text": {"text": "{clusters}", "size": 24, "color": "white", "anchor": "center"}, - "face_color": face_color, - "properties": _position_cluster_labels(self.model.coordinates, vec, face_color), - "metadata": None, - } - - def _hide_points_controls(self, layer: Points, is_categorical: bool) -> None: - try: - # TODO(michalk8): find a better way: https://github.com/napari/napari/issues/3066 - points_controls = self.view.viewer.window._qt_viewer.controls.widgets[layer] - except KeyError: - return - - gl: QGridLayout = points_controls.grid_layout - - labels = {} - for i in range(gl.count()): - item = gl.itemAt(i).widget() - if isinstance(item, QLabel): - labels[item.text()] = item - - label_key, widget = "", None - # remove all widgets which can modify the layer - for label_key, widget_name in _WIDGETS_TO_HIDE.items(): - widget = getattr(points_controls, widget_name, None) - if label_key in labels and widget is not None: - widget.setHidden(True) - labels[label_key].setHidden(True) - - if TYPE_CHECKING: - assert isinstance(widget, QWidget) - - if not is_categorical: # add the slider - if widget is None: - logg.warning("Unable to set the percentile slider") - return - idx = gl.indexOf(widget) - row, *_ = gl.getItemPosition(idx) - - slider = RangeSlider( - layer=layer, - colorbar=self.view._colorbar, - ) - slider.valueChanged.emit((0, 100)) - gl.replaceWidget(labels[label_key], QLabel("percentile:")) - gl.replaceWidget(widget, slider) - - @property - def view(self) -> ImageView: - """View managed by this controller.""" - return self._view - - @property - def model(self) -> ImageModel: - """Model managed by this controller.""" - return self._model diff --git a/src/squidpy/pl/_interactive/_model.py b/src/squidpy/pl/_interactive/_model.py deleted file mode 100644 index a05b2bd0..00000000 --- a/src/squidpy/pl/_interactive/_model.py +++ /dev/null @@ -1,123 +0,0 @@ -from __future__ import annotations - -from collections.abc import Sequence -from dataclasses import dataclass, field -from typing import TYPE_CHECKING - -import numpy as np -from anndata import AnnData - -from squidpy._constants._constants import Symbol -from squidpy._constants._pkg_constants import Key -from squidpy._utils import NDArrayA, _unique_order_preserving -from squidpy.gr._utils import _assert_categorical_obs, _assert_spatial_basis -from squidpy.im import ImageContainer -from squidpy.im._coords import _NULL_COORDS, _NULL_PADDING, CropCoords, CropPadding -from squidpy.pl._utils import ALayer - -__all__ = ["ImageModel"] - - -@dataclass -class ImageModel: - """Model which holds the data for interactive visualization.""" - - adata: AnnData - container: ImageContainer - spatial_key: str = field(default=Key.obsm.spatial, repr=False) - library_key: str | None = None - library_id: str | Sequence[str] | None = None - spot_diameter_key: str = "spot_diameter_fullres" - spot_diameter: NDArrayA | float = field(default=0, init=False) - coordinates: NDArrayA = field(init=False, repr=False) - alayer: ALayer = field(init=False, repr=True) - - palette: str | None = field(default=None, repr=False) - cmap: str = field(default="viridis", repr=False) - blending: str = field(default="opaque", repr=False) - key_added: str = "shapes" - symbol: Symbol = Symbol.DISC - - def __post_init__(self) -> None: - _assert_spatial_basis(self.adata, self.spatial_key) - - self.symbol = Symbol(self.symbol) - self.adata = self.container.subset(self.adata, spatial_key=self.spatial_key) - if not self.adata.n_obs: - raise ValueError("Please ensure that the image contains at least 1 spot.") - self._set_scale_coords() - self._set_library() - - if TYPE_CHECKING: - assert isinstance(self.library_id, Sequence) - - self.alayer = ALayer( - self.adata, - self.library_id, - is_raw=False, - palette=self.palette, - ) - - try: - self.container = ImageContainer._from_dataset(self.container.data.sel(z=self.library_id), deep=None) - except KeyError: - raise KeyError( - f"Unable to subset the image container with library ids `{self.library_id}`. " - f"Valid container library ids are `{self.container.library_ids}`. Please specify a valid `library_id`." - ) from None - - def _set_scale_coords(self) -> None: - self.scale = self.container.data.attrs.get(Key.img.scale, 1) - coordinates = self.adata.obsm[self.spatial_key][:, :2] * self.scale - - c: CropCoords = self.container.data.attrs.get(Key.img.coords, _NULL_COORDS) - p: CropPadding = self.container.data.attrs.get(Key.img.padding, _NULL_PADDING) - if c != _NULL_COORDS: - coordinates -= c.x0 - p.x_pre - coordinates -= c.y0 - p.y_pre - - self.coordinates = coordinates[:, ::-1] - - def _set_library(self) -> None: - if self.library_key is None: - if len(self.container.library_ids) > 1: - raise KeyError( - f"ImageContainer has `{len(self.container.library_ids)}` Z-dimensions. " - f"Please specify `library_key` that maps observations to library ids." - ) - self.coordinates = np.insert(self.coordinates, 0, values=0, axis=1) - self.library_id = self.container.library_ids - if TYPE_CHECKING: - assert isinstance(self.library_id, Sequence) - self.spot_diameter = ( - Key.uns.spot_diameter(self.adata, self.spatial_key, self.library_id[0], self.spot_diameter_key) - * self.scale - ) - return - - _assert_categorical_obs(self.adata, self.library_key) - if self.library_id is None: - self.library_id = self.adata.obs[self.library_key].cat.categories - elif isinstance(self.library_id, str): - self.library_id = [self.library_id] - self.library_id, _ = _unique_order_preserving(self.library_id) # type: ignore[assignment] - - 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 - - libraries = self.adata.obs[self.library_key] - mask = libraries.isin(self.library_id) - libraries = libraries[mask].cat.remove_unused_categories() - self.library_id = list(libraries.cat.categories) - - self.coordinates = np.c_[libraries.cat.codes.values, self.coordinates[mask]] - self.spot_diameter = np.array( - [ - np.array([0.0] + [Key.uns.spot_diameter(self.adata, self.spatial_key, lid, self.spot_diameter_key)] * 2) - * self.scale - for lid in libraries - ] - ) - self.adata = self.adata[mask] diff --git a/src/squidpy/pl/_interactive/_utils.py b/src/squidpy/pl/_interactive/_utils.py deleted file mode 100644 index 8587c399..00000000 --- a/src/squidpy/pl/_interactive/_utils.py +++ /dev/null @@ -1,84 +0,0 @@ -from __future__ import annotations - -import dask.array as da -import numpy as np -import pandas as pd -from anndata import AnnData -from matplotlib.colors import to_hex, to_rgb -from numba import njit -from pandas import CategoricalDtype -from pandas.api.types import infer_dtype -from scanpy import logging as logg -from scipy.spatial import KDTree - -from squidpy._compat import add_colors_for_categorical_sample_annotation -from squidpy._constants._pkg_constants import Key -from squidpy._utils import NDArrayA - - -def _get_categorical( - adata: AnnData, - key: str, - palette: str | None = None, - vec: pd.Series | None = None, -) -> NDArrayA: - if vec is not None: - if not isinstance(vec.dtype, CategoricalDtype): - raise TypeError(f"Expected a `categorical` type, found `{infer_dtype(vec)}`.") - if key in adata.obs: - logg.debug(f"Overwriting `adata.obs[{key!r}]`") - - adata.obs[key] = vec.values - - add_colors_for_categorical_sample_annotation( - adata, key=key, force_update_colors=palette is not None, palette=palette - ) - col_dict = dict( - zip(adata.obs[key].cat.categories, [to_rgb(i) for i in adata.uns[Key.uns.colors(key)]], strict=False) - ) - - return np.array([col_dict[v] for v in adata.obs[key]]) - - -def _position_cluster_labels(coords: NDArrayA, clusters: pd.Series, colors: NDArrayA) -> dict[str, NDArrayA]: - if not isinstance(clusters.dtype, CategoricalDtype): - raise TypeError(f"Expected `clusters` to be `categorical`, found `{infer_dtype(clusters)}`.") - - coords = coords[:, 1:] # TODO(michalk8): account for current Z-dim? - df = pd.DataFrame(coords) - df["clusters"] = clusters.values - df = df.groupby("clusters")[[0, 1]].apply(lambda g: list(np.median(g.values, axis=0))) - df = pd.DataFrame(list(df), index=df.index) - - kdtree = KDTree(coords) - clusters = np.full(len(coords), fill_value="", dtype=object) - # index consists of the categories that need not be string - clusters[kdtree.query(df.values)[1]] = df.index.astype(str) - # napari v0.4.9 - properties must be 1-D in napari/layers/points/points.py:581 - colors = np.array([to_hex(col if cl != "" else (0, 0, 0)) for cl, col in zip(clusters, colors, strict=False)]) - - return {"clusters": clusters, "colors": colors} - - -def _not_in_01(arr: NDArrayA | da.Array) -> bool: - @njit - def _helper_arr(arr: NDArrayA) -> bool: - for val in arr.flat: - if not (0 <= val <= 1): - return True - - return False - - if isinstance(arr, da.Array): - return bool(np.min(arr) < 0 or np.max(arr) > 1) # cast needed - - return bool(_helper_arr(np.asarray(arr))) - - -def _display_channelwise(arr: NDArrayA | da.Array) -> bool: - n_channels: int = arr.shape[-1] - if n_channels not in (3, 4): - return n_channels != 1 - if np.issubdtype(arr.dtype, np.uint8): - return False # assume RGB(A) - return _not_in_01(arr) if np.issubdtype(arr.dtype, np.floating) else True diff --git a/src/squidpy/pl/_interactive/_view.py b/src/squidpy/pl/_interactive/_view.py deleted file mode 100644 index 46f46963..00000000 --- a/src/squidpy/pl/_interactive/_view.py +++ /dev/null @@ -1,184 +0,0 @@ -from __future__ import annotations - -from typing import Any - -import napari -import numpy as np -from napari.layers import Points -from PyQt5.QtWidgets import QComboBox, QHBoxLayout, QLabel, QWidget -from scanpy import logging as logg - -from squidpy.pl._interactive._model import ImageModel -from squidpy.pl._interactive._widgets import ( # type: ignore[attr-defined] - AListWidget, - CBarWidget, - LibraryListWidget, - ObsmIndexWidget, - TwoStateCheckBox, -) - -__all__ = ["ImageView"] - - -class ImageView: - """ - View class which initializes :class:`napari.Viewer`. - - Parameters - ---------- - model - Model for this view. - controller - Controller for this view. - """ - - def __init__(self, model: ImageModel, controller: ImageController): # type: ignore[name-defined] # noqa: F821 - self._model = model - self._controller = controller - - def _init_UI(self) -> None: - def update_library(event: Any) -> None: - value = tuple(event.value) - if len(value) == 3: - lid = value[0] - elif len(value) == 4: - lid = value[1] - else: - logg.error(f"Unable to set library id from `{value}`") - return - - self.model.alayer.library_id = lid - library_id.setText(f"{self.model.alayer.library_id}") - - self._viewer = napari.Viewer(title="Squidpy", show=False) - self.viewer.bind_key("Shift-E", self.controller.export) - parent = self.viewer.window._qt_window - - # image - image_lab = QLabel("Images:") - image_lab.setToolTip("Keys in `ImageContainer` containing the image data.") - image_widget = LibraryListWidget(self.controller, multiselect=False, unique=True) - image_widget.setMaximumHeight(100) - image_widget.addItems(tuple(self.model.container)) - image_widget.setCurrentItem(image_widget.item(0)) - - # gene - var_lab = QLabel("Genes:", parent=parent) - var_lab.setToolTip("Gene names from `adata.var_names` or `adata.raw.var_names`.") - var_widget = AListWidget(self.controller, self.model.alayer, attr="var", parent=parent) - - # obs - obs_label = QLabel("Observations:", parent=parent) - obs_label.setToolTip("Keys in `adata.obs` containing cell observations.") - self._obs_widget = AListWidget(self.controller, self.model.alayer, attr="obs", parent=parent) - - # obsm - obsm_label = QLabel("Obsm:", parent=parent) - obsm_label.setToolTip("Keys in `adata.obsm` containing multidimensional cell information.") - obsm_widget = AListWidget(self.controller, self.model.alayer, attr="obsm", multiselect=False, parent=parent) - obsm_index_widget = ObsmIndexWidget(self.model.alayer, parent=parent) - obsm_index_widget.setToolTip("Indices for current key in `adata.obsm`.") - obsm_index_widget.currentTextChanged.connect(obsm_widget.setIndex) - obsm_widget.itemClicked.connect(obsm_index_widget.addItems) - - # layer selection - layer_label = QLabel("Layers:", parent=parent) - layer_label.setToolTip("Keys in `adata.layers` used when visualizing gene expression.") - layer_widget = QComboBox(parent=parent) - layer_widget.addItem("default", None) - layer_widget.addItems(self.model.adata.layers.keys()) - layer_widget.currentTextChanged.connect(var_widget.setLayer) - layer_widget.setCurrentText("default") - - # raw selection - raw_cbox = TwoStateCheckBox(parent=parent) - raw_cbox.setDisabled(self.model.adata.raw is None) - raw_cbox.checkChanged.connect(layer_widget.setDisabled) - raw_cbox.checkChanged.connect(var_widget.setRaw) - raw_layout = QHBoxLayout() - raw_label = QLabel("Raw:", parent=parent) - raw_label.setToolTip("Whether to access `adata.raw.X` or `adata.X` when visualizing gene expression.") - raw_layout.addWidget(raw_label) - raw_layout.addWidget(raw_cbox) - raw_layout.addStretch() - raw_widget = QWidget(parent=parent) - raw_widget.setLayout(raw_layout) - - library_id = QLabel(f"{self.model.alayer.library_id}") - library_id.setToolTip("Currently selected library id.") - - widgets = ( - image_lab, - image_widget, - layer_label, - layer_widget, - raw_widget, - var_lab, - var_widget, - obs_label, - self._obs_widget, # needed for controller to add mask - obsm_label, - obsm_widget, - obsm_index_widget, - library_id, - ) - self._colorbar = CBarWidget(self.model.cmap, parent=parent) - - self.viewer.window.add_dock_widget(self._colorbar, area="left", name="percentile") - self.viewer.window.add_dock_widget(widgets, area="right", name="genes") - self.viewer.layers.selection.events.changed.connect(self._move_layer_to_front) - self.viewer.layers.selection.events.changed.connect(self._adjust_colorbar) - self.viewer.dims.events.current_step.connect(update_library) - # TODO: find callback that that shows all Z-dimensions and change lib. id to 'All' - - def _move_layer_to_front(self, event: Any) -> None: - try: - layer = next(iter(event.added)) - except StopIteration: - return - if not layer.visible: - return - try: - index = self.viewer.layers.index(layer) - except ValueError: - return - - self.viewer.layers.move(index, -1) - - def _adjust_colorbar(self, event: Any) -> None: - try: - layer = next(layer for layer in event.added if isinstance(layer, Points)) - except StopIteration: - return - - try: - self._colorbar.setOclim(layer.metadata["minmax"]) - self._colorbar.setClim((np.min(layer.properties["value"]), np.max(layer.properties["value"]))) - self._colorbar.update_color() - except KeyError: # categorical - pass - - @property - def layers(self) -> napari.components.layerlist.LayerList: - """List of layers of :attr:`napari.Viewer.layers`.""" - return self.viewer.layers - - @property - def layernames(self) -> frozenset[str]: - """Names of :attr:`napari.Viewer.layers`.""" - return frozenset(layer.name for layer in self.layers) - - @property - def viewer(self) -> napari.Viewer: - """:mod:`napari` viewer.""" - return self._viewer - - @property - def model(self) -> ImageModel: - """Model for this view.""" - return self._model - - @property - def controller(self) -> ImageController: # type: ignore[name-defined] # noqa: F821 - """Controller for this view.""" # noqa: D401 - return self._controller diff --git a/src/squidpy/pl/_interactive/_widgets.py b/src/squidpy/pl/_interactive/_widgets.py deleted file mode 100644 index 879b8bcc..00000000 --- a/src/squidpy/pl/_interactive/_widgets.py +++ /dev/null @@ -1,373 +0,0 @@ -# type: ignore -from __future__ import annotations - -from abc import abstractmethod -from collections.abc import Iterable -from typing import Any - -import numpy as np -import pandas as pd -from deprecated import deprecated -from napari.layers import Points -from PyQt5 import QtCore, QtWidgets -from PyQt5.QtCore import Qt -from scanpy import logging as logg -from superqt import QRangeSlider -from vispy import scene -from vispy.color.colormap import Colormap, MatplotlibColormap -from vispy.scene.widgets import ColorBarWidget - -from squidpy.pl._utils import ALayer - -__all__ = ["TwoStateCheckBox", "AListWidget", "CBarWidget", "RangeSlider", "ObsmIndexWidget", "LibraryListWidget"] - - -class ListWidget(QtWidgets.QListWidget): - indexChanged = QtCore.pyqtSignal(object) - enterPressed = QtCore.pyqtSignal(object) - - def __init__(self, controller: Any, unique: bool = True, multiselect: bool = True, **kwargs: Any): - super().__init__(**kwargs) - if multiselect: - self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) - else: - self.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) - - self._index: int | str = 0 - self._unique = unique - self._controller = controller - - self.itemDoubleClicked.connect(lambda item: self._onAction((item.text(),))) - self.enterPressed.connect(self._onAction) - self.indexChanged.connect(self._onAction) - - @abstractmethod - def setIndex(self, index: int | str) -> None: - pass - - def getIndex(self) -> int | str: - return self._index - - @abstractmethod - def _onAction(self, items: Iterable[str]) -> None: - pass - - def addItems(self, labels: str | Iterable[str]) -> None: - if isinstance(labels, str): - labels = (labels,) - labels = tuple(labels) - - if self._unique: - labels = tuple(label for label in labels if self.findItems(label, QtCore.Qt.MatchExactly) is not None) - - if len(labels): - super().addItems(labels) - self.sortItems(QtCore.Qt.AscendingOrder) - - def keyPressEvent(self, event: QtCore.QEvent) -> None: - if event.key() == QtCore.Qt.Key_Return: - event.accept() - self.enterPressed.emit(tuple(s.text() for s in self.selectedItems())) - else: - super().keyPressEvent(event) - - -class LibraryListWidget(ListWidget): - def __init__(self, controller: Any, **kwargs: Any): - super().__init__(controller, **kwargs) - - self.currentTextChanged.connect(self._onAction) - - def setIndex(self, index: int | str) -> None: - # not used - if index == self._index: - return - - self._index = index - self.indexChanged.emit(tuple(s.text() for s in self.selectedItems())) - - def _onAction(self, items: str | Iterable[str]) -> None: - if isinstance(items, str): - items = (items,) - - for item in items: - if self._controller.add_image(item): - # only add 1 item - break - - -class TwoStateCheckBox(QtWidgets.QCheckBox): - checkChanged = QtCore.pyqtSignal(bool) - - def __init__(self, **kwargs: Any): - super().__init__(**kwargs) - - self.setTristate(False) - self.setChecked(False) - self.stateChanged.connect(self._onStateChanged) - - def _onStateChanged(self, state: QtCore.Qt.CheckState) -> None: - self.checkChanged.emit(state == QtCore.Qt.Checked) - - -class AListWidget(ListWidget): - rawChanged = QtCore.pyqtSignal() - layerChanged = QtCore.pyqtSignal() - libraryChanged = QtCore.pyqtSignal() - - def __init__(self, controller: Any, alayer: ALayer, attr: str, **kwargs: Any): - if attr not in ALayer.VALID_ATTRIBUTES: - raise ValueError(f"Invalid attribute `{attr}`. Valid options are `{sorted(ALayer.VALID_ATTRIBUTES)}`.") - super().__init__(controller, **kwargs) - - self._alayer = alayer - - self._attr = attr - self._getter = getattr(self._alayer, f"get_{attr}") - - self.rawChanged.connect(self._onChange) - self.layerChanged.connect(self._onChange) - self.libraryChanged.connect(self._onChange) - - self._onChange() - - def _onChange(self) -> None: - self.clear() - self.addItems(self._alayer.get_items(self._attr)) - - def _onAction(self, items: Iterable[str]) -> None: - for item in sorted(set(items)): - try: - vec, name = self._getter(item, index=self.getIndex()) - except Exception as e: # noqa: BLE001 - logg.error(e) - continue - self._controller.add_points(vec, key=item, layer_name=name) - - def setRaw(self, is_raw: bool) -> None: - if is_raw == self.getRaw(): - return - - self._alayer.raw = is_raw - self.rawChanged.emit() - - def getRaw(self) -> bool: - return self._alayer.raw - - def setIndex(self, index: str | int) -> None: - if isinstance(index, str): - if index == "": - index = 0 - elif self._attr != "obsm": - index = int(index, base=10) - # for obsm, we convert index to int if needed (if not a DataFrame) in the ALayer - if index == self._index: - return - - self._index = index - if self._attr == "obsm": - self.indexChanged.emit(tuple(s.text() for s in self.selectedItems())) - - def getIndex(self) -> int | str: - return self._index - - def setLayer(self, layer: str | None) -> None: - if layer in ("default", "None"): - layer = None - if layer == self.getLayer(): - return - - self._alayer.layer = layer - self.layerChanged.emit() - - def getLayer(self) -> str | None: - return self._alayer.layer - - def setLibraryId(self, library_id: str) -> None: - if library_id == self.getLibraryId(): - return - - self._alayer.library_id = library_id - self.libraryChanged.emit() - - def getLibraryId(self) -> str: - return self._alayer.library_id - - -class ObsmIndexWidget(QtWidgets.QComboBox): - def __init__(self, alayer: ALayer, max_visible: int = 6, **kwargs: Any): - super().__init__(**kwargs) - - self._alayer = alayer - self.view().setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) - self.setMaxVisibleItems(max_visible) - self.setStyleSheet("combobox-popup: 0;") - - def addItems(self, texts: QtWidgets.QListWidgetItem | int | Iterable[str]) -> None: - if isinstance(texts, QtWidgets.QListWidgetItem): - try: - key = texts.text() - if isinstance(self._alayer.adata.obsm[key], pd.DataFrame): - texts = sorted(self._alayer.adata.obsm[key].select_dtypes(include=[np.number, "category"]).columns) - elif hasattr(self._alayer.adata.obsm[key], "shape"): - texts = self._alayer.adata.obsm[key].shape[1] - else: - texts = np.asarray(self._alayer.adata.obsm[key]).shape[1] - except (KeyError, IndexError): - texts = 0 - if isinstance(texts, int): - texts = tuple(str(i) for i in range(texts)) - - self.clear() - super().addItems(tuple(texts)) - - -class CBarWidget(QtWidgets.QWidget): - FORMAT = "{0:0.2f}" - - cmapChanged = QtCore.pyqtSignal(str) - climChanged = QtCore.pyqtSignal((float, float)) - - def __init__( - self, - cmap: str | Colormap, - label: str | None = None, - width: int | None = 250, - height: int | None = 50, - **kwargs: Any, - ): - super().__init__(**kwargs) - - self._cmap = cmap - self._clim = (0.0, 1.0) - self._oclim = self._clim - - self._width = width - self._height = height - self._label = label - - self.__init_UI() - - def __init_UI(self) -> None: - self.setFixedWidth(self._width) - self.setFixedHeight(self._height) - - # use napari's BG color for dark mode - self._canvas = scene.SceneCanvas( - size=(self._width, self._height), bgcolor="#262930", parent=self, decorate=False, resizable=False, dpi=150 - ) - self._colorbar = ColorBarWidget( - self._create_colormap(self.getCmap()), - orientation="top", - label=self._label, - label_color="white", - clim=self.getClim(), - border_width=1.0, - border_color="black", - padding=(0.33, 0.167), - axis_ratio=0.05, - ) - - self._canvas.central_widget.add_widget(self._colorbar) - - self.climChanged.connect(self.onClimChanged) - self.cmapChanged.connect(self.onCmapChanged) - - def _create_colormap(self, cmap: str) -> Colormap: - ominn, omaxx = self.getOclim() - delta = omaxx - ominn + 1e-12 - - minn, maxx = self.getClim() - minn = (minn - ominn) / delta - maxx = (maxx - ominn) / delta - - assert 0 <= minn <= 1, f"Expected `min` to be in `[0, 1]`, found `{minn}`" - assert 0 <= maxx <= 1, f"Expected `maxx` to be in `[0, 1]`, found `{maxx}`" - - cm = MatplotlibColormap(cmap) - - return Colormap(cm[np.linspace(minn, maxx, len(cm.colors))], interpolation="linear") - - def setCmap(self, cmap: str) -> None: - if self._cmap == cmap: - return - - self._cmap = cmap - self.cmapChanged.emit(cmap) - - def getCmap(self) -> str: - return self._cmap - - def onCmapChanged(self, value: str) -> None: - # this does not trigger update for some reason... - self._colorbar.cmap = self._create_colormap(value) - self._colorbar._colorbar._update() - - def setClim(self, value: tuple[float, float]) -> None: - if value == self._clim: - return - - self._clim = value - self.climChanged.emit(*value) - - def getClim(self) -> tuple[float, float]: - return self._clim - - def getOclim(self) -> tuple[float, float]: - return self._oclim - - def setOclim(self, value: tuple[float, float]) -> None: - # original color limit used for 0-1 normalization - self._oclim = value - - def onClimChanged(self, minn: float, maxx: float) -> None: - # ticks are not working with vispy's colorbar - self._colorbar.cmap = self._create_colormap(self.getCmap()) - self._colorbar.clim = (self.FORMAT.format(minn), self.FORMAT.format(maxx)) - - def getCanvas(self) -> scene.SceneCanvas: - return self._canvas - - def getColorBar(self) -> ColorBarWidget: - return self._colorbar - - def setLayout(self, layout: QtWidgets.QLayout) -> None: - layout.addWidget(self.getCanvas().native) - super().setLayout(layout) - - def update_color(self) -> None: - # when changing selected layers that have the same limit - # could also trigger it as self._colorbar.clim = self.getClim() - # but the above option also updates geometry - # cbarwidget->cbar->cbarvisual - self._colorbar._colorbar._colorbar._update() - - -@deprecated -class RangeSlider(QRangeSlider): - def __init__(self, *args: Any, layer: Points, colorbar: CBarWidget, **kwargs: Any): - super().__init__(*args, **kwargs) - - self._layer = layer - self._colorbar = colorbar - self.setValue((0, 100)) - self.setSliderPosition((0, 100)) - self.setSingleStep(0.01) - self.setOrientation(Qt.Horizontal) - - self.valueChanged.connect(self._onValueChange) - - def _onValueChange(self, percentile: tuple[float, float]) -> None: - # TODO(michalk8): use constants - v = self._layer.metadata["data"] - clipped = np.clip(v, *np.percentile(v, percentile)) - - self._layer.metadata = {**self._layer.metadata, "perc": percentile} - self._layer.face_color = "value" - self._layer.properties = {"value": clipped} - self._layer._update_thumbnail() # can't find another way to force it - self._layer.refresh_colors() - - self._colorbar.setOclim(self._layer.metadata["minmax"]) - self._colorbar.setClim((np.min(self._layer.properties["value"]), np.max(self._layer.properties["value"]))) - self._colorbar.update_color() diff --git a/src/squidpy/pl/_interactive/interactive.py b/src/squidpy/pl/_interactive/interactive.py deleted file mode 100644 index b2328923..00000000 --- a/src/squidpy/pl/_interactive/interactive.py +++ /dev/null @@ -1,129 +0,0 @@ -from __future__ import annotations - -""" -interactive.py - -WARNING: This module is deprecated and will be removed in a future version. -Please use `https://github.com/scverse/napari-spatialdata` instead. -""" - -raise ImportError("The squidpy napari plugin is deprecated, please use https://github.com/scverse/napari-spatialdata") - -from typing import Any - -import matplotlib.pyplot as plt -from anndata import AnnData -from scanpy import logging as logg - -from squidpy._docs import d -from squidpy._utils import NDArrayA, deprecated -from squidpy.im import ImageContainer -from squidpy.pl._utils import save_fig - -try: - from squidpy.pl._interactive._controller import ImageController -except ImportError as e: - _error: str | None = str(e) -else: - _error = None - - -__all__ = ["Interactive"] - - -@d.dedent -class Interactive: - """ - Interactive viewer for spatial data. - - Parameters - ---------- - %(img_container)s - %(_interactive.parameters)s - """ - - @deprecated( - reason="The squidpy napari plugin is deprecated, please use https://github.com/scverse/napari-spatialdata", - ) - def __init__(self, img: ImageContainer, adata: AnnData, **kwargs: Any): - if _error is not None: - raise ImportError(f"Unable to import the interactive viewer. Reason `{_error}`.") - - self._controller = ImageController(adata, img, **kwargs) - - def show(self, restore: bool = False) -> Interactive: - """ - Launch the :class:`napari.Viewer`. - - Parameters - ---------- - restore - Whether to reinitialize the GUI after it has been destroyed. - - Returns - ------- - Nothing, just launches the viewer. - """ - self._controller.show(restore=restore) - return self - - @d.dedent - def screenshot( - self, - return_result: bool = False, - dpi: float | None = 180, - save: str | None = None, - canvas_only: bool = True, - **kwargs: Any, - ) -> NDArrayA | None: - """ - Plot a screenshot of the viewer's canvas. - - Parameters - ---------- - return_result - If `True`, return the image as an :class:`numpy.uint8`. - dpi - Dots per inch. - save - Whether to save the plot. - canvas_only - Whether to show only the canvas or also the widgets. - kwargs - Keyword arguments for :meth:`matplotlib.axes.Axes.imshow`. - - Returns - ------- - Nothing, if ``return_result = False``, otherwise the image array. - """ - try: - arr = self._controller.screenshot(path=None, canvas_only=canvas_only) - except RuntimeError as e: - logg.error(f"Unable to take a screenshot. Reason: {e}") - return None - - fig, ax = plt.subplots(nrows=1, ncols=1, dpi=dpi) - fig.tight_layout() - - ax.imshow(arr, **kwargs) - plt.axis("off") - - if save is not None: - save_fig(fig, save) - - return arr if return_result else None - - def close(self) -> None: - """Close the viewer.""" - self._controller.close() - - @property - def adata(self) -> AnnData: - """Annotated data object.""" - return self._controller.model.adata - - def __repr__(self) -> str: - return f"Interactive view of {repr(self._controller.model.container)}" - - def __str__(self) -> str: - return repr(self) diff --git a/tests/_images/Napari_add_image.png b/tests/_images/Napari_add_image.png deleted file mode 100644 index 5eb4b300..00000000 Binary files a/tests/_images/Napari_add_image.png and /dev/null differ diff --git a/tests/_images/Napari_blending.png b/tests/_images/Napari_blending.png deleted file mode 100644 index b146cde2..00000000 Binary files a/tests/_images/Napari_blending.png and /dev/null differ diff --git a/tests/_images/Napari_cat_cmap.png b/tests/_images/Napari_cat_cmap.png deleted file mode 100644 index 26d8cfc1..00000000 Binary files a/tests/_images/Napari_cat_cmap.png and /dev/null differ diff --git a/tests/_images/Napari_cont_cmap.png b/tests/_images/Napari_cont_cmap.png deleted file mode 100644 index b4b48c0e..00000000 Binary files a/tests/_images/Napari_cont_cmap.png and /dev/null differ diff --git a/tests/_images/Napari_corner_case_-200_-200_600_800.png b/tests/_images/Napari_corner_case_-200_-200_600_800.png deleted file mode 100644 index 0d78c307..00000000 Binary files a/tests/_images/Napari_corner_case_-200_-200_600_800.png and /dev/null differ diff --git a/tests/_images/Napari_corner_case_-200_-200_800_600.png b/tests/_images/Napari_corner_case_-200_-200_800_600.png deleted file mode 100644 index 4da190a7..00000000 Binary files a/tests/_images/Napari_corner_case_-200_-200_800_600.png and /dev/null differ diff --git a/tests/_images/Napari_corner_case_-200_-200_800_800.png b/tests/_images/Napari_corner_case_-200_-200_800_800.png deleted file mode 100644 index 1730e045..00000000 Binary files a/tests/_images/Napari_corner_case_-200_-200_800_800.png and /dev/null differ diff --git a/tests/_images/Napari_corner_case_-200_200_600_800.png b/tests/_images/Napari_corner_case_-200_200_600_800.png deleted file mode 100644 index dca7ec85..00000000 Binary files a/tests/_images/Napari_corner_case_-200_200_600_800.png and /dev/null differ diff --git a/tests/_images/Napari_corner_case_-200_200_800_600.png b/tests/_images/Napari_corner_case_-200_200_800_600.png deleted file mode 100644 index 9975ff26..00000000 Binary files a/tests/_images/Napari_corner_case_-200_200_800_600.png and /dev/null differ diff --git a/tests/_images/Napari_corner_case_-200_200_800_800.png b/tests/_images/Napari_corner_case_-200_200_800_800.png deleted file mode 100644 index 67284e4f..00000000 Binary files a/tests/_images/Napari_corner_case_-200_200_800_800.png and /dev/null differ diff --git a/tests/_images/Napari_corner_case_200_-200_600_800.png b/tests/_images/Napari_corner_case_200_-200_600_800.png deleted file mode 100644 index 59658c02..00000000 Binary files a/tests/_images/Napari_corner_case_200_-200_600_800.png and /dev/null differ diff --git a/tests/_images/Napari_corner_case_200_-200_800_600.png b/tests/_images/Napari_corner_case_200_-200_800_600.png deleted file mode 100644 index 9705e6ab..00000000 Binary files a/tests/_images/Napari_corner_case_200_-200_800_600.png and /dev/null differ diff --git a/tests/_images/Napari_corner_case_200_-200_800_800.png b/tests/_images/Napari_corner_case_200_-200_800_800.png deleted file mode 100644 index 28e21d9e..00000000 Binary files a/tests/_images/Napari_corner_case_200_-200_800_800.png and /dev/null differ diff --git a/tests/_images/Napari_corner_case_200_200_600_800.png b/tests/_images/Napari_corner_case_200_200_600_800.png deleted file mode 100644 index a2253ca2..00000000 Binary files a/tests/_images/Napari_corner_case_200_200_600_800.png and /dev/null differ diff --git a/tests/_images/Napari_corner_case_200_200_800_600.png b/tests/_images/Napari_corner_case_200_200_800_600.png deleted file mode 100644 index 41f7efb2..00000000 Binary files a/tests/_images/Napari_corner_case_200_200_800_600.png and /dev/null differ diff --git a/tests/_images/Napari_corner_case_200_200_800_800.png b/tests/_images/Napari_corner_case_200_200_800_800.png deleted file mode 100644 index fd00793a..00000000 Binary files a/tests/_images/Napari_corner_case_200_200_800_800.png and /dev/null differ diff --git a/tests/_images/Napari_crop_center.png b/tests/_images/Napari_crop_center.png deleted file mode 100644 index 20511635..00000000 Binary files a/tests/_images/Napari_crop_center.png and /dev/null differ diff --git a/tests/_images/Napari_crop_corner.png b/tests/_images/Napari_crop_corner.png deleted file mode 100644 index bab5afcb..00000000 Binary files a/tests/_images/Napari_crop_corner.png and /dev/null differ diff --git a/tests/_images/Napari_gene_X.png b/tests/_images/Napari_gene_X.png deleted file mode 100644 index cb920cb8..00000000 Binary files a/tests/_images/Napari_gene_X.png and /dev/null differ diff --git a/tests/_images/Napari_obs_categorical.png b/tests/_images/Napari_obs_categorical.png deleted file mode 100644 index 1a1ae8bd..00000000 Binary files a/tests/_images/Napari_obs_categorical.png and /dev/null differ diff --git a/tests/_images/Napari_obs_continuous.png b/tests/_images/Napari_obs_continuous.png deleted file mode 100644 index 4c7badc7..00000000 Binary files a/tests/_images/Napari_obs_continuous.png and /dev/null differ diff --git a/tests/_images/Napari_scalefactor.png b/tests/_images/Napari_scalefactor.png deleted file mode 100644 index d5c06309..00000000 Binary files a/tests/_images/Napari_scalefactor.png and /dev/null differ diff --git a/tests/_images/Napari_simple_canvas.png b/tests/_images/Napari_simple_canvas.png deleted file mode 100644 index 97278713..00000000 Binary files a/tests/_images/Napari_simple_canvas.png and /dev/null differ diff --git a/tests/_images/Napari_symbol.png b/tests/_images/Napari_symbol.png deleted file mode 100644 index 78147ec4..00000000 Binary files a/tests/_images/Napari_symbol.png and /dev/null differ diff --git a/tests/_images/Napari_viewer_canvas.png b/tests/_images/Napari_viewer_canvas.png deleted file mode 100644 index f4e8c096..00000000 Binary files a/tests/_images/Napari_viewer_canvas.png and /dev/null differ diff --git a/tests/conftest.py b/tests/conftest.py index 1dfe7b0a..e4a11d0e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -238,15 +238,6 @@ def cont_dot() -> ImageContainer: return ImageContainer(img_orig, layer="image_0") -@pytest.fixture() -def napari_cont() -> ImageContainer: - return ImageContainer( - "tests/_data/test_img.jpg", - layer="V1_Adult_Mouse_Brain", - library_id="V1_Adult_Mouse_Brain", - ) - - @pytest.fixture() def interactions(adata: AnnData) -> tuple[Sequence[str], Sequence[str]]: return tuple(product(adata.raw.var_names[:5], adata.raw.var_names[:5])) # type: ignore @@ -429,32 +420,13 @@ def compare(cls, basename: str, tolerance: float | None = None): plt.close() if tolerance is None: - # see https://github.com/scverse/squidpy/pull/302 - tolerance = 2 * TOL if "Napari" in str(basename) else TOL + tolerance = TOL res = compare_images(str(EXPECTED / f"{basename}.png"), str(out_path), tolerance) assert res is None, res -def pytest_addoption(parser): - parser.addoption("--test-napari", action="store_true", help="Test interactive image view") - - -def pytest_collection_modifyitems(config, items): - if config.getoption("--test-napari"): - return - skip_slow = pytest.mark.skip(reason="Need --test-napari option to test interactive image view") - for item in items: - if "qt" in item.keywords: - item.add_marker(skip_slow) - - -@pytest.fixture(scope="session") -def _test_napari(pytestconfig): - _ = pytestconfig.getoption("--test-napari", skip=True) - - @pytest.fixture() def adjacency_matrix(): return np.array( diff --git a/tests/plotting/test_interactive.py b/tests/plotting/test_interactive.py deleted file mode 100644 index 9d1549f9..00000000 --- a/tests/plotting/test_interactive.py +++ /dev/null @@ -1,163 +0,0 @@ -from __future__ import annotations - -import platform -import sys - -import matplotlib.pyplot as plt -import numpy as np -import pytest -from anndata import AnnData -from matplotlib.testing.compare import compare_images -from scanpy import settings as s -from scipy.sparse import issparse - -from squidpy.im import ImageContainer -from tests.conftest import ACTUAL, DPI, EXPECTED, TOL, PlotTester, PlotTesterMeta - - -@pytest.mark.skipif(platform.system() == "Linux", reason="Fails on ubuntu") -@pytest.mark.qt() -class TestNapari(PlotTester, metaclass=PlotTesterMeta): - def test_add_same_layer(self, adata: AnnData, napari_cont: ImageContainer, capsys): - from napari.layers import Points - - s.logfile = sys.stderr - s.verbosity = 4 - - viewer = napari_cont.interactive(adata) - cnt = viewer._controller - - data = np.random.normal(size=adata.n_obs) - cnt.add_points(data, layer_name="layer1") - cnt.add_points(np.random.normal(size=adata.n_obs), layer_name="layer1") - - err = capsys.readouterr().err - - assert "Layer `layer1` is already loaded" in err - assert len(viewer._controller.view.layers) == 2 - assert viewer._controller.view.layernames == {"V1_Adult_Mouse_Brain", "layer1"} - assert isinstance(viewer._controller.view.layers["layer1"], Points) - np.testing.assert_array_equal(viewer._controller.view.layers["layer1"].metadata["data"], data) - - def test_add_not_categorical_series(self, qtbot, adata: AnnData, napari_cont: ImageContainer): - viewer = napari_cont.interactive(adata) - cnt = viewer._controller - - with pytest.raises(TypeError, match=r"Expected a `categorical` type,.*"): - cnt.add_points(adata.obs["in_tissue"].astype(int), layer_name="layer1") - - def test_plot_simple_canvas(self, qtbot, adata: AnnData, napari_cont: ImageContainer): - viewer = napari_cont.interactive(adata) - - viewer.screenshot(dpi=DPI) - - def test_plot_symbol(self, qtbot, adata: AnnData, napari_cont: ImageContainer): - viewer = napari_cont.interactive(adata, symbol="square") - cnt = viewer._controller - - cnt.add_points(adata.obs_vector(adata.var_names[42]), layer_name="foo") - viewer.screenshot(dpi=DPI) - - def test_plot_gene_X(self, qtbot, adata: AnnData, napari_cont: ImageContainer): - viewer = napari_cont.interactive(adata) - cnt = viewer._controller - - cnt.add_points(adata.obs_vector(adata.var_names[42]), layer_name="foo") - viewer.screenshot(dpi=DPI) - - def test_plot_obs_continuous(self, qtbot, adata: AnnData, napari_cont: ImageContainer): - viewer = napari_cont.interactive(adata) - cnt = viewer._controller - - cnt.add_points(np.random.RandomState(42).normal(size=adata.n_obs), layer_name="quux") - viewer.screenshot(dpi=DPI) - - def test_plot_obs_categorical(self, qtbot, adata: AnnData, napari_cont: ImageContainer): - viewer = napari_cont.interactive(adata) - cnt = viewer._controller - - cnt.add_points(adata.obs["leiden"], key="leiden", layer_name="quas") - viewer.screenshot(dpi=DPI) - - def test_plot_cont_cmap(self, qtbot, adata: AnnData, napari_cont: ImageContainer): - viewer = napari_cont.interactive(adata, cmap="inferno") - cnt = viewer._controller - - cnt.add_points(adata.obs_vector(adata.var_names[42]), layer_name="wex") - viewer.screenshot(dpi=DPI) - - def test_plot_cat_cmap(self, qtbot, adata: AnnData, napari_cont: ImageContainer): - viewer = napari_cont.interactive(adata, palette="Set3") - cnt = viewer._controller - - cnt.add_points(adata.obs["leiden"].astype("category"), key="in_tissue", layer_name="exort") - viewer.screenshot(dpi=DPI) - - def test_plot_blending(self, qtbot, adata: AnnData, napari_cont: ImageContainer): - viewer = napari_cont.interactive(adata, blending="additive") - cnt = viewer._controller - - for gene in adata.var_names[42:46]: - data = adata.obs_vector(gene) - if issparse(data): # ALayer handles sparsity, here we have to do it ourselves - data = data.X - cnt.add_points(data, layer_name=gene) - - viewer.screenshot(dpi=DPI) - - def test_plot_crop_center(self, qtbot, adata: AnnData, napari_cont: ImageContainer): - viewer = napari_cont.crop_corner(0, 0, size=500).interactive(adata) - bdata = viewer.adata - cnt = viewer._controller - - cnt.add_points(bdata.obs_vector(bdata.var_names[42]), layer_name="foo") - - viewer.screenshot(dpi=DPI) - - @pytest.mark.skip(reason="Soon to be deprecated.") - def test_plot_crop_corner(self, qtbot, adata: AnnData, napari_cont: ImageContainer): - viewer = napari_cont.crop_center(500, 500, radius=250).interactive(adata) - bdata = viewer.adata - cnt = viewer._controller - - cnt.add_points(bdata.obs_vector(bdata.var_names[42]), layer_name="foo") - - viewer.screenshot(dpi=DPI) - - def test_plot_scalefactor(self, qtbot, adata: AnnData, napari_cont: ImageContainer): - scale = 2 - napari_cont.data.attrs["scale"] = scale - - viewer = napari_cont.interactive(adata) - cnt = viewer._controller - model = cnt._model - - data = np.random.normal(size=adata.n_obs) - cnt.add_points(data, layer_name="layer1") - - # ignore z-dim - np.testing.assert_allclose(adata.obsm["spatial"][:, ::-1] * scale, model.coordinates[:, 1:]) - - viewer.screenshot(dpi=DPI) - - @pytest.mark.skip(reason="Soon to be deprecated.") - @pytest.mark.parametrize("size", [(800, 600), (600, 800), (800, 800)]) - @pytest.mark.parametrize("x", [-200, 200]) - @pytest.mark.parametrize("y", [-200, 200]) - def test_corner_corner_cases( - self, qtbot, adata: AnnData, napari_cont: ImageContainer, y: int, x: int, size: tuple[int, int] - ): - viewer = napari_cont.crop_corner(y, x, size=size).interactive(adata) - bdata = viewer.adata - cnt = viewer._controller - - cnt.add_points(bdata.obs_vector(bdata.var_names[42]), layer_name="foo") - - basename = f"{self.__class__.__name__[4:]}_corner_case_{y}_{x}_{'_'.join(map(str, size))}.png" - viewer.screenshot(dpi=DPI) - plt.savefig(ACTUAL / basename, dpi=DPI) - plt.close() - - res = compare_images(str(EXPECTED / basename), str(ACTUAL / basename), 2 * TOL) - - assert res is None, res