Repository: scverse/scanpy

Issue:
scanpy.pl.scatter doesn't accept strings as color palettes
The docs for `sc.pl.scatter` say 

> The palette can be a valid ListedColormap name ('Set2', 'tab20', …)

but setting `palette` to a string throws an error

```python
adata = sc.datasets.paul15()
sc.pl.scatter(adata, "Cma1", "Irf8", color='paul15_clusters', palette="Set2")
```

```pytb
... storing 'paul15_clusters' as categorical
Trying to set attribute `.uns` of view, copying.
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-698-58a5366a0f70> in <module>
      1 adata = sc.datasets.paul15()
----> 2 sc.pl.scatter(adata, "Cma1", "Irf8", color='paul15_clusters', palette="Set2")

~/.local/lib/python3.7/site-packages/scanpy/plotting/_anndata.py in scatter(adata, x, y, color, use_raw, layers, sort_order, alpha, basis, groups, components, projection, legend_loc, legend_fontsize, legend_fontweight, legend_fontoutline, color_map, palette, frameon, right_margin, left_margin, size, title, show, save, ax)
    126         and (color is None or color in adata.obs.keys() or color in adata.var.index)
    127     ):
--> 128         return _scatter_obs(**args)
    129     if (
    130         (x in adata.var.keys() or x in adata.obs.index)

~/.local/lib/python3.7/site-packages/scanpy/plotting/_anndata.py in _scatter_obs(adata, x, y, color, use_raw, layers, sort_order, alpha, basis, groups, components, projection, legend_loc, legend_fontsize, legend_fontweight, legend_fontoutline, color_map, palette, frameon, right_margin, left_margin, size, title, show, save, ax)
    273         palettes = [palette for _ in range(len(keys))]
    274     for i, palette in enumerate(palettes):
--> 275         palettes[i] = _utils.default_palette(palette)
    276 
    277     if basis is not None:

TypeError: 'str' object does not support item assignment
```

I get no error if I use any of `sc.pl.palettes`. I also get no error setting `palette="Set2"` in `sc.pl.umap`, `sc.pl.draw_graph` etc...

#### Versions

<details>

-----
anndata     0.7.4
scanpy      1.6.0
sinfo       0.3.1
-----
MulticoreTSNE       NA
PIL                 7.2.0
anndata             0.7.4
appdirs             1.4.4
atac_utils          NA
atomicwrites        1.3.0
attr                20.2.0
backcall            0.2.0
brotli              NA
cellrank            1.0.0-rc.10
certifi             2020.06.20
cffi                1.14.3
chardet             3.0.4
colorama            0.4.3
cycler              0.10.0
cython_runtime      NA
datacache           1.1.5
dateutil            2.8.1
decorator           4.4.2
docrep              0.3.1
fcsparser           0.2.1
future_fstrings     NA
get_version         2.1
google              NA
gtfparse            1.2.0
h5py                2.10.0
idna                2.10
igraph              0.8.2
importlib_metadata  0.23
ipykernel           5.3.4
ipython_genutils    0.2.0
ipywidgets          7.5.1
jedi                0.17.2
jinja2              2.11.2
joblib              0.14.0
jsonschema          3.2.0
kiwisolver          1.1.0
legacy_api_wrap     1.2
leidenalg           0.8.1
libpetsc4py         NA
llvmlite            0.34.0
louvain             0.6.1+2.g8073db7
markupsafe          1.1.1
matplotlib          3.3.2
memoized_property   NA
more_itertools      NA
mpl_toolkits        NA
natsort             6.0.0
nbformat            5.0.7
networkx            2.3
numba               0.51.2
numexpr             2.7.0
numpy               1.19.1
packaging           20.4
palantir            1.0.0
pandas              1.1.2
parso               0.7.1
petsc4py            3.13.0
pexpect             4.8.0
phenograph          1.5.6
pickleshare         0.7.5
pkg_resources       NA
pluggy              0.13.0
progressbar         3.53.1
prometheus_client   NA
prompt_toolkit      3.0.7
psutil              5.7.2
ptyprocess          0.6.0
pvectorc            NA
py                  1.8.0
pyensembl           1.8.7
pygam               0.8.0
pygments            2.7.1
pyparsing           2.4.2
pyrsistent          NA
pytest              5.2.1
python_utils        NA
pytz                2019.2
requests            2.24.0
rpy2                3.3.5
scanpy              1.6.0
scipy               1.5.2
scvelo              0.2.2
seaborn             0.11.0
send2trash          NA
serializable        0.2.1
setuptools_scm      NA
simplejson          3.17.2
sinfo               0.3.1
six                 1.15.0
sklearn             0.21.3
slepc4py            3.13.0
socks               1.7.1
sphinxcontrib       NA
statsmodels         0.10.1
storemagic          NA
tables              3.5.2
terminado           0.9.1
texttable           1.6.3
tornado             6.0.4
traitlets           4.3.3
typechecks          NA
typing_extensions   NA
tzlocal             NA
urllib3             1.25.10
wcwidth             0.2.5
wrapt               1.12.1
yaml                5.1.2
zipp                NA
zmq                 19.0.2
-----
IPython             7.18.1
jupyter_client      6.1.7
jupyter_core        4.6.3
notebook            6.1.4
-----
Python 3.7.8 | packaged by conda-forge | (default, Jul 31 2020, 02:25:08) [GCC 7.5.0]
Linux-4.4.0-189-generic-x86_64-with-debian-buster-sid
24 logical CPU cores, x86_64
-----
Session information updated at 2020-09-30 12:20

</details>



Discussion:
Still happens in 2023. If I use `palette=sc.pl.palettes.zeileis_28` it works, but when I use `palette='Set2'` I got the same error
Hi @JackieMium, I remember you said something similar in another issue.

If there’s things bugging you, how about making a PR that fixes it?
> Hi @JackieMium, I remember you said something similar in another issue.
> 
> If there’s things bugging you, how about making a PR that fixes it?

Not sure what you're referring to but I don't think I ever reported color pallette issue before. 
I hope I could help fix things but I am familiar with R/Seurat and Python/scanpy is a whole new universe to me. I am starting to learning the scanpy pipeline. How things work under the hood with scanpy or basically Python plotting are really beyond my capabilities.
Yeah, plotting in python is difficult, and our plotting code doesn’t make things simpler. This is the relevant part:

https://github.com/scverse/scanpy/blob/0594b7f03917f8c5166d5bb2752031e1665065de/scanpy/plotting/_anndata.py#L273-L284

The code that behaves like advertised in the docs is in here, but that function does more things after that:

https://github.com/scverse/scanpy/blob/0594b7f03917f8c5166d5bb2752031e1665065de/scanpy/plotting/_utils.py#L364

There’s also this:

https://github.com/scverse/scanpy/blob/0594b7f03917f8c5166d5bb2752031e1665065de/scanpy/plotting/_tools/scatterplots.py#L1181

I think things should be unified so they use the same palette selection logic. But I understand that that’s a pretty complex part of our code base.

I meant this comment: https://github.com/scverse/scanpy/issues/1258#issuecomment-1626690231
I don't know how to fix this in the code, but I do find a workaround to specify a color palette. Turns out both `sc.pl.umap()` and `sc.pl.scatter()` accept a color palette if it's from `seaborn` color palette:

```py
sc.pl.scatter(adata, 'n_genes', 'n_counts', color='louvain', palette='Set3')
```

throws the above error message. But if I use

```py
sc.pl.scatter(adata, 'n_genes', 'n_counts', color='louvain', palette=sns.color_palette('Set3'))
```

if works as expected. Also you can manually assign a color list as :

```py
sc.pl.scatter(adata, 'n_genes', 'n_counts', color='louvain', palette=[
    '#000000', '#575757', '#AD2323', '#2A4BD7', '#1D6914', 
    '#814A19', '#8126C0', '#A0A0A0', '#81C57A', '#9DAFFF', 
    '#29D0D0', '#FF9233', '#FFEE33', '#E9DEBB', '#FFCDF3', 
    '#F2F3F4'
])
```

`palette=sc.pl.palettes.zeileis_28` works because `sc.pl.palettes.zeileis_28` is already a list of color.

This also works for the `palete` argument  in`sc.pl.umap()`, but it changes the `adata.uns['louvain_colors']` column values and will change other plots when using this column for plotting.

Hope this helps.


Or just the standard matplotlib palettes, yes:

```py
from matplotlib import cm

sc.pl.scatter(adata, 'n_genes', 'n_counts', color='louvain', palette=cm.get_cmap('Set3'))
```

The bug is that you can’t pass the colormap name. Passing a colormap directly works.
> Or just the standard matplotlib palettes, yes:
> 
> ```python
> from matplotlib import cm
> 
> sc.pl.scatter(adata, 'n_genes', 'n_counts', color='louvain', palette=cm.get_cmap('Set3'))
> ```
> 
> The bug is that you can’t pass the colormap name. Passing a colormap directly works.

Is it then, a good idea to state this the documents for the time being to clear the confusion? 
probably a similar amount of work to fix it.

Resolving patch:
diff --git a/scanpy/plotting/_anndata.py b/scanpy/plotting/_anndata.py
--- a/scanpy/plotting/_anndata.py
+++ b/scanpy/plotting/_anndata.py
@@ -273,15 +273,14 @@ def _scatter_obs(
     palette_was_none = False
     if palette is None:
         palette_was_none = True
-    if isinstance(palette, cabc.Sequence):
+    if isinstance(palette, cabc.Sequence) and not isinstance(palette, str):
         if not is_color_like(palette[0]):
             palettes = palette
         else:
             palettes = [palette]
     else:
         palettes = [palette for _ in range(len(keys))]
-    for i, palette in enumerate(palettes):
-        palettes[i] = _utils.default_palette(palette)
+    palettes = [_utils.default_palette(palette) for palette in palettes]
 
     if basis is not None:
         component_name = (
@@ -375,8 +374,7 @@ def add_centroid(centroids, name, Y, mask):
         centroids[name] = Y_mask[i]
 
     # loop over all categorical annotation and plot it
-    for i, ikey in enumerate(categoricals):
-        palette = palettes[i]
+    for ikey, palette in zip(categoricals, palettes):
         key = keys[ikey]
         _utils.add_colors_for_categorical_sample_annotation(
             adata, key, palette, force_update_colors=not palette_was_none
diff --git a/scanpy/plotting/_utils.py b/scanpy/plotting/_utils.py
--- a/scanpy/plotting/_utils.py
+++ b/scanpy/plotting/_utils.py
@@ -1,7 +1,7 @@
+from __future__ import annotations
+
 import warnings
 import collections.abc as cabc
-from abc import ABC
-from functools import lru_cache
 from typing import Union, List, Sequence, Tuple, Collection, Optional, Callable, Literal
 import anndata
 
@@ -320,10 +320,12 @@ def savefig_or_show(
         pl.close()  # clear figure
 
 
-def default_palette(palette: Union[Sequence[str], Cycler, None] = None) -> Cycler:
+def default_palette(
+    palette: Union[str, Sequence[str], Cycler, None] = None
+) -> str | Cycler:
     if palette is None:
         return rcParams['axes.prop_cycle']
-    elif not isinstance(palette, Cycler):
+    elif not isinstance(palette, (str, Cycler)):
         return cycler(color=palette)
     else:
         return palette


Test patch:
diff --git a/scanpy/tests/_images/scatter_HES_percent_mito_bulk_labels/expected.png b/scanpy/tests/_images/scatter_HES_percent_mito_bulk_labels/expected.png
new file mode 100644
Binary files /dev/null and b/scanpy/tests/_images/scatter_HES_percent_mito_bulk_labels/expected.png differ
diff --git a/scanpy/tests/test_neighbors.py b/scanpy/tests/test_neighbors.py
--- a/scanpy/tests/test_neighbors.py
+++ b/scanpy/tests/test_neighbors.py
@@ -145,7 +145,7 @@ def test_distances_euclidean(mocker, neigh, method):
         ),
     ],
 )
-def test_distances_all(neigh, transformer, knn):
+def test_distances_all(neigh: Neighbors, transformer, knn):
     neigh.compute_neighbors(
         n_neighbors, transformer=transformer, method='gauss', knn=knn
     )
diff --git a/scanpy/tests/test_plotting.py b/scanpy/tests/test_plotting.py
--- a/scanpy/tests/test_plotting.py
+++ b/scanpy/tests/test_plotting.py
@@ -1290,14 +1290,23 @@ def test_scatter_specify_layer_and_raw():
         sc.pl.umap(pbmc, color="HES4", use_raw=True, layer="layer")
 
 
-def test_scatter_no_basis_per_obs(image_comparer):
+@pytest.mark.parametrize("color", ["n_genes", "bulk_labels"])
+def test_scatter_no_basis_per_obs(image_comparer, color):
     """Test scatterplot of per-obs points with no basis"""
 
     save_and_compare_images = partial(image_comparer, ROOT, tol=15)
 
     pbmc = pbmc68k_reduced()
-    sc.pl.scatter(pbmc, x="HES4", y="percent_mito", color="n_genes", use_raw=False)
-    save_and_compare_images("scatter_HES_percent_mito_n_genes")
+    sc.pl.scatter(
+        pbmc,
+        x="HES4",
+        y="percent_mito",
+        color=color,
+        use_raw=False,
+        # palette only applies to categorical, i.e. color=='bulk_labels'
+        palette='Set2',
+    )
+    save_and_compare_images(f"scatter_HES_percent_mito_{color}")
 
 
 def test_scatter_no_basis_per_var(image_comparer):