Repository: scverse/scanpy

Issue:
UMAP plotting can not properly colour np.nan values in categorical obs
- [x] I have checked that this issue has not already been reported.
- [x] I have confirmed this bug exists on the latest version of scanpy.
- [ ] (optional) I have confirmed this bug exists on the master branch of scanpy.

Scanpy version: 1.5.1

If  np.nan values are present in obs column used for colouring `sc.pl.umap` they are coloured with the colour of the last non-nan category (see example below). This is very problematic as cells that are not annotated get the color of one of the categories.
This is another reason why https://github.com/theislab/scanpy/issues/1340 is needed - there should be a default for nan (such as gray).

I plotted an UMAP and coloured it by cell type (left). Then I replaced half of alpha cells' cell types (column pre_cell_type) with np.nan and plotted it again (right). Half of the alpha cells (previously coloured blue) are now coloured pink (colour of the last category, here named 'NA'). These cells should not be coloured pink - nan should have its own colour.

![slika](https://user-images.githubusercontent.com/47607471/89209123-fbe75b00-d5bd-11ea-9a46-6d3dd628cb31.png)




Discussion:

Resolving patch:
diff --git a/scanpy/plotting/_docs.py b/scanpy/plotting/_docs.py
--- a/scanpy/plotting/_docs.py
+++ b/scanpy/plotting/_docs.py
@@ -89,6 +89,12 @@
     If `None`, `mpl.rcParams["axes.prop_cycle"]` is used unless the categorical
     variable already has colors stored in `adata.uns["{var}_colors"]`.
     If provided, values of `adata.uns["{var}_colors"]` will be set.
+na_color
+    Color to use for null or masked values. Can be anything matplotlib accepts as a
+    color. Used for all points if `color=None`.
+na_in_legend
+    If there are missing values, whether they get an entry in the legend. Currently
+    only implemented for categorical legends.
 frameon
     Draw a frame around the scatter plot. Defaults to value set in
     :func:`~scanpy.set_figure_params`, defaults to `True`.
diff --git a/scanpy/plotting/_tools/scatterplots.py b/scanpy/plotting/_tools/scatterplots.py
--- a/scanpy/plotting/_tools/scatterplots.py
+++ b/scanpy/plotting/_tools/scatterplots.py
@@ -1,13 +1,16 @@
 import collections.abc as cabc
+from copy import copy
 from typing import Union, Optional, Sequence, Any, Mapping, List, Tuple, Callable
 
 import numpy as np
+import pandas as pd
 from anndata import AnnData
 from cycler import Cycler
 from matplotlib.axes import Axes
 from matplotlib.figure import Figure
 from pandas.api.types import is_categorical_dtype
 from matplotlib import pyplot as pl, colors
+from matplotlib.cm import get_cmap
 from matplotlib import rcParams
 from matplotlib import patheffects
 from matplotlib.colors import Colormap
@@ -19,6 +22,7 @@
     _FontWeight,
     _FontSize,
     circles,
+    ColorLike,
     make_projection_available,
 )
 from .._docs import (
@@ -67,7 +71,10 @@ def embedding(
     library_id: str = None,
     #
     color_map: Union[Colormap, str, None] = None,
+    cmap: Union[Colormap, str, None] = None,
     palette: Union[str, Sequence[str], Cycler, None] = None,
+    na_color: ColorLike = "lightgray",
+    na_in_legend: bool = True,
     size: Union[float, Sequence[float], None] = None,
     frameon: Optional[bool] = None,
     legend_fontsize: Union[int, float, _FontSize, None] = None,
@@ -107,8 +114,20 @@ def embedding(
     """
 
     sanitize_anndata(adata)
+
+    # Setting up color map for continuous values
     if color_map is not None:
-        kwargs['cmap'] = color_map
+        if cmap is not None:
+            raise ValueError("Cannot specify both `color_map` and `cmap`.")
+        else:
+            cmap = color_map
+    cmap = copy(get_cmap(cmap))
+    cmap.set_bad(na_color)
+    kwargs["cmap"] = cmap
+
+    # Prevents warnings during legend creation
+    na_color = colors.to_hex(na_color, keep_alpha=True)
+
     if size is not None:
         kwargs['s'] = size
     if 'edgecolor' not in kwargs:
@@ -225,28 +244,36 @@ def embedding(
     for count, (value_to_plot, component_idx) in enumerate(
         itertools.product(color, idx_components)
     ):
-        color_vector, categorical = _get_color_values(
+        color_source_vector = _get_color_source_vector(
             adata,
             value_to_plot,
             layer=layer,
-            groups=groups,
-            palette=palette,
             use_raw=use_raw,
             gene_symbols=gene_symbols,
+            groups=groups,
+        )
+        color_vector, categorical = _color_vector(
+            adata,
+            value_to_plot,
+            color_source_vector,
+            palette=palette,
+            na_color=na_color,
         )
 
-        # check if higher value points should be plot on top
+        ### Order points
+        order = slice(None)
         if sort_order is True and value_to_plot is not None and categorical is False:
-            order = np.argsort(color_vector)
-            color_vector = color_vector[order]
-            _data_points = data_points[component_idx][order, :]
-
-            # check if 'size' is given (stored in kwargs['s']
-            # and reorder it.
-            if isinstance(size, np.ndarray):
-                size = np.array(size)[order]
-        else:
-            _data_points = data_points[component_idx]
+            # Higher values plotted on top, null values on bottom
+            order = np.argsort(-color_vector, kind="stable")[::-1]
+        elif sort_order and categorical:
+            # Null points go on bottom
+            order = np.argsort(~pd.isnull(color_source_vector), kind="stable")
+        # Set orders
+        if isinstance(size, np.ndarray):
+            size = np.array(size)[order]
+        color_source_vector = color_source_vector[order]
+        color_vector = color_vector[order]
+        _data_points = data_points[component_idx][order, :]
 
         # if plotting multiple panels, get the ax from the grid spec
         # else use the ax value (either user given or created previously)
@@ -304,7 +331,7 @@ def embedding(
                 size_spot = 70 * size
 
             scatter = (
-                partial(ax.scatter, s=size)
+                partial(ax.scatter, s=size, plotnonfinite=True)
                 if library_id is None
                 else partial(circles, s=size_spot, ax=ax)
             )
@@ -355,50 +382,14 @@ def embedding(
                 # if user did not set alpha, set alpha to 0.7
                 kwargs['alpha'] = 0.7 if alpha is None else alpha
 
-            if groups:
-                # first plot non-groups and then plot the
-                # required groups on top
-
-                in_groups = np.array(adata.obs[value_to_plot].isin(groups))
-
-                if isinstance(size, np.ndarray):
-                    in_groups_size = size[in_groups]
-                    not_in_groups_size = size[~in_groups]
-                elif img_key is not None:
-                    in_groups_size = not_in_groups_size = size_spot
-                else:
-                    in_groups_size = not_in_groups_size = size
-
-                # only show grey points if no image is below
-                if library_id is None:
-                    ax.scatter(
-                        _data_points[~in_groups, 0],
-                        _data_points[~in_groups, 1],
-                        s=not_in_groups_size,
-                        marker=".",
-                        c=color_vector[~in_groups],
-                        rasterized=settings._vector_friendly,
-                        **kwargs,
-                    )
-                cax = scatter(
-                    _data_points[in_groups, 0],
-                    _data_points[in_groups, 1],
-                    s=in_groups_size,
-                    marker=".",
-                    c=color_vector[in_groups],
-                    rasterized=settings._vector_friendly,
-                    **kwargs,
-                )
-
-            else:
-                cax = scatter(
-                    _data_points[:, 0],
-                    _data_points[:, 1],
-                    marker=".",
-                    c=color_vector,
-                    rasterized=settings._vector_friendly,
-                    **kwargs,
-                )
+            cax = scatter(
+                _data_points[:, 0],
+                _data_points[:, 1],
+                marker=".",
+                c=color_vector,
+                rasterized=settings._vector_friendly,
+                **kwargs,
+            )
 
         # remove y and x ticks
         ax.set_yticks([])
@@ -412,7 +403,6 @@ def embedding(
             axis_labels = [name + str(x + 1) for x in components_list[component_idx]]
         elif projection == '3d':
             axis_labels = [name + str(x + 1) for x in range(3)]
-
         else:
             axis_labels = [name + str(x + 1) for x in range(2)]
 
@@ -440,20 +430,24 @@ def embedding(
         else:
             path_effect = None
 
-        _add_legend_or_colorbar(
-            adata,
-            ax,
-            cax,
-            categorical,
-            value_to_plot,
-            legend_loc,
-            _data_points,
-            legend_fontweight,
-            legend_fontsize,
-            path_effect,
-            groups,
-            bool(grid),
-        )
+        # Adding legends
+        if categorical:
+            _add_categorical_legend(
+                ax,
+                color_source_vector,
+                palette=_get_palette(adata, value_to_plot),
+                scatter_array=_data_points,
+                legend_loc=legend_loc,
+                legend_fontweight=legend_fontweight,
+                legend_fontsize=legend_fontsize,
+                legend_fontoutline=path_effect,
+                na_color=na_color,
+                na_in_legend=na_in_legend,
+                multi_panel=bool(grid),
+            )
+        else:
+            # TODO: na_in_legend should have some effect here
+            pl.colorbar(cax, ax=ax, pad=0.01, fraction=0.08, aspect=30)
 
     if return_fig is True:
         return fig
@@ -551,7 +545,7 @@ def my_vmax(color_vector): np.percentile(color_vector, p=80)
                         f"Please check the correct format for percentiles."
                     )
                 # interpret value of vmin/vmax as quantile with the following syntax 'p99.9'
-                v_value = np.percentile(color_vector, q=float(v_value[1:]))
+                v_value = np.nanpercentile(color_vector, q=float(v_value[1:]))
             elif callable(v_value):
                 # interpret vmin/vmax as function
                 v_value = v_value(color_vector)
@@ -576,14 +570,30 @@ def my_vmax(color_vector): np.percentile(color_vector, p=80)
 
 
 def _wraps_plot_scatter(wrapper):
-    annots_orig = {
-        k: v for k, v in wrapper.__annotations__.items() if k not in {'adata', 'kwargs'}
-    }
-    annots_scatter = {
-        k: v for k, v in embedding.__annotations__.items() if k != 'basis'
+    import inspect
+
+    params = inspect.signature(embedding).parameters.copy()
+    wrapper_sig = inspect.signature(wrapper)
+    wrapper_params = wrapper_sig.parameters.copy()
+
+    params.pop("basis")
+    params.pop("kwargs")
+    wrapper_params.pop("adata")
+
+    params.update(wrapper_params)
+    annotations = {
+        k: v.annotation
+        for k, v in params.items()
+        if v.annotation != inspect.Parameter.empty
     }
-    wrapper.__annotations__ = {**annots_scatter, **annots_orig}
-    wrapper.__wrapped__ = embedding
+    if wrapper_sig.return_annotation is not inspect.Signature.empty:
+        annotations["return"] = wrapper_sig.return_annotation
+
+    wrapper.__signature__ = inspect.Signature(
+        list(params.values()), return_annotation=wrapper_sig.return_annotation
+    )
+    wrapper.__annotations__ = annotations
+
     return wrapper
 
 
@@ -671,7 +681,7 @@ def diffmap(adata, **kwargs) -> Union[Axes, List[Axes], None]:
     show_save_ax=doc_show_save_ax,
 )
 def draw_graph(
-    adata: AnnData, layout: Optional[_IGraphLayout] = None, **kwargs,
+    adata: AnnData, *, layout: Optional[_IGraphLayout] = None, **kwargs,
 ) -> Union[Axes, List[Axes], None]:
     """\
     Scatter plot in graph-drawing basis.
@@ -741,6 +751,7 @@ def spatial(
     alpha_img: float = 1.0,
     bw: bool = False,
     size: float = None,
+    na_color: ColorLike = (0.0, 0.0, 0.0, 0.0),
     **kwargs,
 ) -> Union[Axes, List[Axes], None]:
     """\
@@ -790,6 +801,7 @@ def spatial(
         bw=bw,
         library_id=library_id,
         size=size,
+        na_color=na_color,
         **kwargs,
     )
 
@@ -922,108 +934,82 @@ def _get_data_points(
     return data_points, components_list
 
 
-def _add_legend_or_colorbar(
-    adata,
+def _add_categorical_legend(
     ax,
-    cax,
-    categorical,
-    value_to_plot,
-    legend_loc,
-    scatter_array,
+    color_source_vector,
+    palette: dict,
+    legend_loc: str,
     legend_fontweight,
     legend_fontsize,
     legend_fontoutline,
-    groups,
     multi_panel,
+    na_color,
+    na_in_legend: bool,
+    scatter_array=None,
 ):
-    """
-    Adds a color bar or a legend to the given ax. A legend is added when the
-    data is categorical and a color bar is added when a continuous value was used.
+    """Add a legend to the passed Axes."""
+    if na_in_legend and pd.isnull(color_source_vector).any():
+        if "NA" in color_source_vector:
+            raise NotImplementedError(
+                "No fallback for null labels has been defined if NA already in categories."
+            )
+        color_source_vector = color_source_vector.add_categories("NA").fillna("NA")
+        palette = palette.copy()
+        palette["NA"] = na_color
+    cats = color_source_vector.categories
+
+    if multi_panel is True:
+        # Shrink current axis by 10% to fit legend and match
+        # size of plots that are not categorical
+        box = ax.get_position()
+        ax.set_position([box.x0, box.y0, box.width * 0.91, box.height])
+
+    if legend_loc == 'right margin':
+        for label in cats:
+            ax.scatter([], [], c=palette[label], label=label)
+        ax.legend(
+            frameon=False,
+            loc='center left',
+            bbox_to_anchor=(1, 0.5),
+            ncol=(1 if len(cats) <= 14 else 2 if len(cats) <= 30 else 3),
+            fontsize=legend_fontsize,
+        )
+    elif legend_loc == 'on data':
+        # identify centroids to put labels
+        all_pos = (
+            pd.DataFrame(scatter_array, columns=["x", "y"])
+            .groupby(color_source_vector, observed=True)
+            .median()
+        )
 
-    """
-    # add legends or colorbars
-    if categorical is True:
-        # add legend to figure
-        categories = list(adata.obs[value_to_plot].cat.categories)
-        colors = adata.uns[value_to_plot + '_colors']
-
-        if multi_panel is True:
-            # Shrink current axis by 10% to fit legend and match
-            # size of plots that are not categorical
-            box = ax.get_position()
-            ax.set_position([box.x0, box.y0, box.width * 0.91, box.height])
-
-        if groups is not None:
-            # only label groups with the respective color
-            colors = [colors[categories.index(x)] for x in groups]
-            categories = groups
-
-        if legend_loc == 'right margin':
-            for idx, label in enumerate(categories):
-                color = colors[idx]
-                # use empty scatter to set labels
-                ax.scatter([], [], c=color, label=label)
-            ax.legend(
-                frameon=False,
-                loc='center left',
-                bbox_to_anchor=(1, 0.5),
-                ncol=(
-                    1 if len(categories) <= 14 else 2 if len(categories) <= 30 else 3
-                ),
+        for label, x_pos, y_pos in all_pos.itertuples():
+            ax.text(
+                x_pos,
+                y_pos,
+                label,
+                weight=legend_fontweight,
+                verticalalignment='center',
+                horizontalalignment='center',
                 fontsize=legend_fontsize,
+                path_effects=legend_fontoutline,
             )
-
-        if legend_loc == 'on data':
-            # identify centroids to put labels
-            all_pos = np.zeros((len(categories), 2))
-            for ilabel, label in enumerate(categories):
-                _scatter = scatter_array[adata.obs[value_to_plot] == label, :]
-                x_pos, y_pos = np.median(_scatter, axis=0)
-
-                ax.text(
-                    x_pos,
-                    y_pos,
-                    label,
-                    weight=legend_fontweight,
-                    verticalalignment='center',
-                    horizontalalignment='center',
-                    fontsize=legend_fontsize,
-                    path_effects=legend_fontoutline,
-                )
-
-                all_pos[ilabel] = [x_pos, y_pos]
-            # this is temporary storage for access by other tools
-            _utils._tmp_cluster_pos = all_pos
-    else:
-        # add colorbar to figure
-        pl.colorbar(cax, ax=ax, pad=0.01, fraction=0.08, aspect=30)
+        # TODO: wtf
+        # this is temporary storage for access by other tools
+        _utils._tmp_cluster_pos = all_pos.values
 
 
-def _get_color_values(
-    adata,
-    value_to_plot,
-    groups=None,
-    palette: Union[str, Sequence[str], Cycler, None] = None,
-    use_raw=False,
-    gene_symbols=None,
-    layer=None,
-) -> Tuple[Union[np.ndarray, str], bool]:
+def _get_color_source_vector(
+    adata, value_to_plot, use_raw=False, gene_symbols=None, layer=None, groups=None,
+):
     """
-    Returns the value or color associated to each data point.
-    For categorical data, the return value is list of colors taken
-    from the category palette or from the given `palette` value.
-
-    For non-categorical data, the values are returned
-
-    Returns
-    -------
-    values
-        Values to plot
-    is_categorical
-        Are the values categorical?
+    Get array from adata that colors will be based on.
     """
     if value_to_plot is None:
-        return "lightgray", False
+        # Points will be plotted with `na_color`. Ideally this would work
+        # with the "bad color" in a color map but that throws a warning. Instead
+        # _color_vector handles this.
+        # https://github.com/matplotlib/matplotlib/issues/18294
+        return np.broadcast_to(np.nan, adata.n_obs)
     if (
         gene_symbols is not None
         and value_to_plot not in adata.obs.columns
@@ -1037,35 +1023,54 @@ def _get_color_values(
         values = adata.raw.obs_vector(value_to_plot)
     else:
         values = adata.obs_vector(value_to_plot, layer=layer)
+    if groups and is_categorical_dtype(values):
+        values = values.replace(values.categories.difference(groups), np.nan,)
+    return values
+
+
+def _get_palette(adata, values_key: str, palette=None):
+    color_key = f"{values_key}_colors"
+    values = pd.Categorical(adata.obs[values_key])
+    if palette:
+        _utils._set_colors_for_categorical_obs(adata, values_key, palette)
+    elif color_key not in adata.uns or len(adata.uns[color_key]) < len(
+        values.categories
+    ):
+        #  set a default palette in case that no colors or few colors are found
+        _utils._set_default_colors_for_categorical_obs(adata, values_key)
+    else:
+        _utils._validate_palette(adata, values_key)
+    return dict(zip(values.categories, adata.uns[color_key]))
 
+
+def _color_vector(
+    adata, values_key: str, values, palette, na_color="lightgray"
+) -> Tuple[np.ndarray, bool]:
+    """
+    Map array of values to array of hex (plus alpha) codes.
+
+    For categorical data, the return value is list of colors taken
+    from the category palette or from the given `palette` value.
+
+    For continuous values, the input array is returned (may change in future).
+    """
     ###
     # when plotting, the color of the dots is determined for each plot
     # the data is either categorical or continuous and the data could be in
     # 'obs' or in 'var'
+    to_hex = partial(colors.to_hex, keep_alpha=True)
+    if values_key is None:
+        return np.broadcast_to(to_hex(na_color), adata.n_obs), False
     if not is_categorical_dtype(values):
         return values, False
     else:  # is_categorical_dtype(values)
-        color_key = f"{value_to_plot}_colors"
-        if palette:
-            _utils._set_colors_for_categorical_obs(adata, value_to_plot, palette)
-        elif color_key not in adata.uns or len(adata.uns[color_key]) < len(
-            values.categories
-        ):
-            #  set a default palette in case that no colors or few colors are found
-            _utils._set_default_colors_for_categorical_obs(adata, value_to_plot)
-        else:
-            _utils._validate_palette(adata, value_to_plot)
+        color_map = _get_palette(adata, values_key, palette=palette)
+        color_vector = values.map(color_map).map(to_hex)
 
-        color_vector = np.asarray(adata.uns[color_key])[values.codes]
-
-        # Handle groups
-        if groups:
-            color_vector = np.fromiter(
-                map(colors.to_hex, color_vector), '<U15', len(color_vector)
-            )
-            # set color to 'light gray' for all values
-            # that are not in the groups
-            color_vector[~adata.obs[value_to_plot].isin(groups)] = "lightgray"
+        # Set color to 'missing color' for all missing values
+        if color_vector.isna().any():
+            color_vector = color_vector.add_categories([to_hex(na_color)])
+            color_vector = color_vector.fillna(to_hex(na_color))
         return color_vector, True
 
 
diff --git a/scanpy/plotting/_utils.py b/scanpy/plotting/_utils.py
--- a/scanpy/plotting/_utils.py
+++ b/scanpy/plotting/_utils.py
@@ -1127,7 +1127,7 @@ def circles(x, y, s, ax, marker=None, c='b', vmin=None, vmax=None, **kwargs):
     patches = [Circle((x_, y_), s_) for x_, y_, s_ in zipped]
     collection = PatchCollection(patches, **kwargs)
     if isinstance(c, np.ndarray) and np.issubdtype(c.dtype, np.number):
-        collection.set_array(c)
+        collection.set_array(np.ma.masked_invalid(c))
         collection.set_clim(vmin, vmax)
     else:
         collection.set_facecolor(c)


Test patch:
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.False-legend.off-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.False-legend.off-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.False-legend.off-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.False-legend.off-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.False-legend.off-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.False-legend.off-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.False-legend.on_data-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.False-legend.on_data-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.False-legend.on_data-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.False-legend.on_data-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.False-legend.on_data-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.False-legend.on_data-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.False-legend.on_right-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.False-legend.on_right-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.False-legend.on_right-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.False-legend.on_right-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.False-legend.on_right-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.False-legend.on_right-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.True-legend.off-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.True-legend.off-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.True-legend.off-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.True-legend.off-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.True-legend.off-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.True-legend.off-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.True-legend.on_data-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.True-legend.on_data-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.True-legend.on_data-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.True-legend.on_data-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.True-legend.on_data-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.True-legend.on_data-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.True-legend.on_right-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.True-legend.on_right-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.True-legend.on_right-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.True-legend.on_right-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.True-legend.on_right-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.black_tup-na_in_legend.True-legend.on_right-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.False-legend.off-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.False-legend.off-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.False-legend.off-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.False-legend.off-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.False-legend.off-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.False-legend.off-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.False-legend.on_data-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.False-legend.on_data-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.False-legend.on_data-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.False-legend.on_data-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.False-legend.on_data-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.False-legend.on_data-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.False-legend.on_right-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.False-legend.on_right-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.False-legend.on_right-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.False-legend.on_right-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.False-legend.on_right-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.False-legend.on_right-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.True-legend.off-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.True-legend.off-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.True-legend.off-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.True-legend.off-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.True-legend.off-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.True-legend.off-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.True-legend.on_data-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.True-legend.on_data-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.True-legend.on_data-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.True-legend.on_data-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.True-legend.on_data-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.True-legend.on_data-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.True-legend.on_right-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.True-legend.on_right-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.True-legend.on_right-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.True-legend.on_right-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.True-legend.on_right-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[pca-na_color.default-na_in_legend.True-legend.on_right-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.False-legend.off-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.False-legend.off-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.False-legend.off-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.False-legend.off-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.False-legend.off-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.False-legend.off-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.False-legend.on_data-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.False-legend.on_data-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.False-legend.on_data-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.False-legend.on_data-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.False-legend.on_data-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.False-legend.on_data-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.False-legend.on_right-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.False-legend.on_right-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.False-legend.on_right-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.False-legend.on_right-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.False-legend.on_right-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.False-legend.on_right-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.True-legend.off-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.True-legend.off-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.True-legend.off-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.True-legend.off-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.True-legend.off-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.True-legend.off-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.True-legend.on_data-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.True-legend.on_data-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.True-legend.on_data-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.True-legend.on_data-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.True-legend.on_data-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.True-legend.on_data-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.True-legend.on_right-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.True-legend.on_right-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.True-legend.on_right-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.True-legend.on_right-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.True-legend.on_right-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.black_tup-na_in_legend.True-legend.on_right-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.False-legend.off-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.False-legend.off-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.False-legend.off-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.False-legend.off-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.False-legend.off-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.False-legend.off-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.False-legend.on_data-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.False-legend.on_data-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.False-legend.on_data-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.False-legend.on_data-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.False-legend.on_data-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.False-legend.on_data-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.False-legend.on_right-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.False-legend.on_right-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.False-legend.on_right-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.False-legend.on_right-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.False-legend.on_right-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.False-legend.on_right-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.True-legend.off-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.True-legend.off-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.True-legend.off-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.True-legend.off-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.True-legend.off-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.True-legend.off-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.True-legend.on_data-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.True-legend.on_data-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.True-legend.on_data-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.True-legend.on_data-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.True-legend.on_data-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.True-legend.on_data-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.True-legend.on_right-groups.3].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.True-legend.on_right-groups.3].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.True-legend.on_right-groups.3].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.True-legend.on_right-groups.all].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.True-legend.on_right-groups.all].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_categorical[spatial-na_color.default-na_in_legend.True-legend.on_right-groups.all].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.off-vbound.numbers].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.off-vbound.numbers].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.off-vbound.numbers].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.off-vbound.percentile].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.off-vbound.percentile].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.off-vbound.percentile].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.off-vbounds.default].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.off-vbounds.default].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.off-vbounds.default].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.on_data-vbound.numbers].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.on_data-vbound.numbers].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.on_data-vbound.numbers].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.on_data-vbound.percentile].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.on_data-vbound.percentile].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.on_data-vbound.percentile].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.on_data-vbounds.default].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.on_data-vbounds.default].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.on_data-vbounds.default].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.on_right-vbound.numbers].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.on_right-vbound.numbers].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.on_right-vbound.numbers].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.on_right-vbound.percentile].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.on_right-vbound.percentile].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.on_right-vbound.percentile].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.on_right-vbounds.default].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.on_right-vbounds.default].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.black_tup-legend.on_right-vbounds.default].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.off-vbound.numbers].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.off-vbound.numbers].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.off-vbound.numbers].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.off-vbound.percentile].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.off-vbound.percentile].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.off-vbound.percentile].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.off-vbounds.default].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.off-vbounds.default].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.off-vbounds.default].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.on_data-vbound.numbers].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.on_data-vbound.numbers].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.on_data-vbound.numbers].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.on_data-vbound.percentile].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.on_data-vbound.percentile].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.on_data-vbound.percentile].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.on_data-vbounds.default].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.on_data-vbounds.default].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.on_data-vbounds.default].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.on_right-vbound.numbers].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.on_right-vbound.numbers].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.on_right-vbound.numbers].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.on_right-vbound.percentile].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.on_right-vbound.percentile].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.on_right-vbound.percentile].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.on_right-vbounds.default].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.on_right-vbounds.default].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[pca-na_color.default-legend.on_right-vbounds.default].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.off-vbound.numbers].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.off-vbound.numbers].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.off-vbound.numbers].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.off-vbound.percentile].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.off-vbound.percentile].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.off-vbound.percentile].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.off-vbounds.default].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.off-vbounds.default].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.off-vbounds.default].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.on_data-vbound.numbers].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.on_data-vbound.numbers].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.on_data-vbound.numbers].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.on_data-vbound.percentile].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.on_data-vbound.percentile].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.on_data-vbound.percentile].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.on_data-vbounds.default].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.on_data-vbounds.default].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.on_data-vbounds.default].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.on_right-vbound.numbers].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.on_right-vbound.numbers].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.on_right-vbound.numbers].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.on_right-vbound.percentile].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.on_right-vbound.percentile].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.on_right-vbound.percentile].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.on_right-vbounds.default].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.on_right-vbounds.default].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.black_tup-legend.on_right-vbounds.default].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.off-vbound.numbers].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.off-vbound.numbers].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.off-vbound.numbers].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.off-vbound.percentile].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.off-vbound.percentile].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.off-vbound.percentile].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.off-vbounds.default].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.off-vbounds.default].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.off-vbounds.default].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.on_data-vbound.numbers].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.on_data-vbound.numbers].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.on_data-vbound.numbers].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.on_data-vbound.percentile].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.on_data-vbound.percentile].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.on_data-vbound.percentile].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.on_data-vbounds.default].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.on_data-vbounds.default].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.on_data-vbounds.default].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.on_right-vbound.numbers].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.on_right-vbound.numbers].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.on_right-vbound.numbers].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.on_right-vbound.percentile].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.on_right-vbound.percentile].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.on_right-vbound.percentile].png differ
diff --git a/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.on_right-vbounds.default].png b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.on_right-vbounds.default].png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/embedding-missing-values/test_missing_values_continuous[spatial-na_color.default-legend.on_right-vbounds.default].png differ
diff --git a/scanpy/tests/test_embedding_plots.py b/scanpy/tests/test_embedding_plots.py
new file mode 100644
--- /dev/null
+++ b/scanpy/tests/test_embedding_plots.py
@@ -0,0 +1,171 @@
+from pathlib import Path
+
+import matplotlib.pyplot as plt
+import numpy as np
+import pandas as pd
+import pytest
+
+import scanpy as sc
+
+from scanpy.tests.test_plotting import ROOT, FIGS
+
+MISSING_VALUES_ROOT = ROOT / "embedding-missing-values"
+MISSING_VALUES_FIGS = FIGS / "embedding-missing-values"
+
+
+@pytest.fixture(scope="module")
+def adata():
+    """A bit cute."""
+    from matplotlib.image import imread
+    from sklearn.datasets import make_blobs
+    from sklearn.cluster import DBSCAN
+
+    empty_pixel = np.array([1.0, 1.0, 1.0, 0]).reshape(1, 1, -1)
+    image = imread(
+        Path(sc.__file__).parent.parent / "docs/_static/img/Scanpy_Logo_RGB.png"
+    )
+    x, y = np.where(np.logical_and.reduce(~np.equal(image, empty_pixel), axis=2))
+
+    # Just using to calculate the hex coords
+    hexes = plt.hexbin(x, y, gridsize=(44, 100))
+    counts = hexes.get_array()
+    pixels = hexes.get_offsets()[counts != 0]
+    plt.close()
+
+    labels = DBSCAN(eps=20, min_samples=2).fit(pixels).labels_
+    order = np.argsort(labels)
+    adata = sc.AnnData(
+        make_blobs(
+            pd.value_counts(labels[order]).values,
+            n_features=20,
+            shuffle=False,
+            random_state=42,
+        )[0],
+        obs={"label": pd.Categorical(labels[order].astype(str))},
+        obsm={"spatial": pixels[order, ::-1]},
+        uns={
+            "spatial": {
+                "scanpy_img": {
+                    "images": {"hires": image},
+                    "scalefactors": {
+                        "tissue_hires_scalef": 1,
+                        "spot_diameter_fullres": 10,
+                    },
+                }
+            }
+        },
+    )
+    sc.pp.pca(adata)
+
+    # Adding some missing values
+    adata.obs["label_missing"] = adata.obs["label"].copy()
+    adata.obs["label_missing"][::2] = np.nan
+
+    adata.obs["1_missing"] = adata.obs_vector("1")
+    adata.obs.loc[
+        adata.obsm["spatial"][:, 0] < adata.obsm["spatial"][:, 0].mean(), "1_missing"
+    ] = np.nan
+
+    return adata
+
+
+@pytest.fixture
+def fixture_request(request):
+    """Returns a Request object. 
+
+    Allows you to access names of parameterized tests from within a test.
+    """
+    return request
+
+
+@pytest.fixture(
+    params=[(0, 0, 0, 1), None], ids=["na_color.black_tup", "na_color.default"],
+)
+def na_color(request):
+    return request.param
+
+
+@pytest.fixture(params=[True, False], ids=["na_in_legend.True", "na_in_legend.False"])
+def na_in_legend(request):
+    return request.param
+
+
+@pytest.fixture(params=[sc.pl.pca, sc.pl.spatial])
+def plotfunc(request):
+    return request.param
+
+
+@pytest.fixture(
+    params=["on data", "right margin", None],
+    ids=["legend.on_data", "legend.on_right", "legend.off"],
+)
+def legend_loc(request):
+    return request.param
+
+
+@pytest.fixture(
+    params=[lambda x: list(x.cat.categories[:3]), lambda x: []],
+    ids=["groups.3", "groups.all"],
+)
+def groupsfunc(request):
+    return request.param
+
+
+@pytest.fixture(
+    params=[(None, None), (0, 5), ("p15", "p90")],
+    ids=["vbounds.default", "vbound.numbers", "vbound.percentile"],
+)
+def vbounds(request):
+    return request.param
+
+
+def test_missing_values_categorical(
+    fixture_request,
+    image_comparer,
+    adata,
+    plotfunc,
+    na_color,
+    na_in_legend,
+    legend_loc,
+    groupsfunc,
+):
+    save_and_compare_images = image_comparer(
+        MISSING_VALUES_ROOT, MISSING_VALUES_FIGS, tol=15
+    )
+    base_name = fixture_request.node.name
+
+    # Passing through a dict so it's easier to use default values
+    kwargs = {}
+    kwargs["legend_loc"] = legend_loc
+    kwargs["groups"] = groupsfunc(adata.obs["label"])
+    if na_color is not None:
+        kwargs["na_color"] = na_color
+    kwargs["na_in_legend"] = na_in_legend
+
+    plotfunc(
+        adata, color=["label", "label_missing"], **kwargs,
+    )
+
+    save_and_compare_images(base_name)
+
+
+def test_missing_values_continuous(
+    fixture_request, image_comparer, adata, plotfunc, na_color, legend_loc, vbounds,
+):
+    save_and_compare_images = image_comparer(
+        MISSING_VALUES_ROOT, MISSING_VALUES_FIGS, tol=15
+    )
+    base_name = fixture_request.node.name
+
+    # Passing through a dict so it's easier to use default values
+    kwargs = {}
+    kwargs["vmin"], kwargs["vmax"] = vbounds
+    kwargs["legend_loc"] = legend_loc
+    if na_color is not None:
+        kwargs["na_color"] = na_color
+
+    plotfunc(
+        adata, color=["1", "1_missing"], **kwargs,
+    )
+
+    save_and_compare_images(base_name)