Repository: scverse/scanpy

Issue:
Plot fails after reloading adata when providing RGBA colors in .uns
### Please make sure these conditions are met

- [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.

### What happened?

RGBA colors that are provided as tuples or lists in `adata.uns[f"{key}_colors"]` are converted to numpy arrays when saving and loading the adata. This leads plots (e.g. `pl.pca` or `pl.umap`) to run into an error (see below).

### Minimal code sample

```python
adata = sc.datasets.pbmc3k_processed()
adata = adata[adata.obs["louvain"].isin(["Dendritic cells","Megakaryocytes"])].copy()
adata.uns["louvain_colors"] = [[1, 0, 0, 1], [0, 0, 1, 1]] # or [(1, 0, 0, 1), (0, 0, 1, 1)]
sc.pl.pca(adata, color="louvain")

adata.write("./data/pbmc3k_processed_rgba.h5ad")

adata = sc.read("./data/pbmc3k_processed_rgba.h5ad")
print(type(adata.uns["louvain_colors"][0]))  # --> numpy.ndarray
sc.pl.pca(adata, color="louvain")
```


### Error output

```pytb
ValueError                                Traceback (most recent call last)
...
      8 adata = sc.read("./data/pbmc3k_processed_rgba.h5ad")
      9 print(type(adata.uns["louvain_colors"][0]))  # --> numpy.ndarray
---> 10 sc.pl.pca(adata, color="louvain")

File ~/opt/anaconda3/envs/sc_test/lib/python3.11/site-packages/scanpy/plotting/_tools/scatterplots.py:893, in pca(adata, annotate_var_explained, show, return_fig, save, **kwargs)
    845 """\
    846 Scatter plot in PCA coordinates.
    847 
   (...)
    890 pp.pca
    891 """
    892 if not annotate_var_explained:
--> 893     return embedding(
    894         adata, 'pca', show=show, return_fig=return_fig, save=save, **kwargs
    895     )
    896 else:
    897     if 'pca' not in adata.obsm.keys() and 'X_pca' not in adata.obsm.keys():

File ~/opt/anaconda3/envs/sc_test/lib/python3.11/site-packages/scanpy/plotting/_tools/scatterplots.py:267, in embedding(adata, basis, color, gene_symbols, use_raw, sort_order, edges, edges_width, edges_color, neighbors_key, arrows, arrows_kwds, groups, components, dimensions, layer, projection, scale_factor, color_map, cmap, palette, na_color, na_in_legend, size, frameon, legend_fontsize, legend_fontweight, legend_loc, legend_fontoutline, colorbar_loc, vmax, vmin, vcenter, norm, add_outline, outline_width, outline_color, ncols, hspace, wspace, title, show, save, ax, return_fig, **kwargs)
    258 for count, (value_to_plot, dims) in enumerate(zip(color, dimensions)):
    259     color_source_vector = _get_color_source_vector(
    260         adata,
    261         value_to_plot,
   (...)
    265         groups=groups,
    266     )
--> 267     color_vector, categorical = _color_vector(
    268         adata,
    269         value_to_plot,
    270         color_source_vector,
    271         palette=palette,
    272         na_color=na_color,
    273     )
    275     # Order points
    276     order = slice(None)

File ~/opt/anaconda3/envs/sc_test/lib/python3.11/site-packages/scanpy/plotting/_tools/scatterplots.py:1247, in _color_vector(adata, values_key, values, palette, na_color)
   1243 if values.dtype == bool:
   1244     values = pd.Categorical(values.astype(str))
   1245 color_map = {
   1246     k: to_hex(v)
-> 1247     for k, v in _get_palette(adata, values_key, palette=palette).items()
   1248 }
   1249 # If color_map does not have unique values, this can be slow as the
   1250 # result is not categorical
   1251 color_vector = pd.Categorical(values.map(color_map))

File ~/opt/anaconda3/envs/sc_test/lib/python3.11/site-packages/scanpy/plotting/_tools/scatterplots.py:1214, in _get_palette(adata, values_key, palette)
   1212     _utils._set_default_colors_for_categorical_obs(adata, values_key)
   1213 else:
-> 1214     _utils._validate_palette(adata, values_key)
   1215 return dict(zip(values.categories, adata.uns[color_key]))

File ~/opt/anaconda3/envs/sc_test/lib/python3.11/site-packages/scanpy/plotting/_utils.py:357, in _validate_palette(adata, key)
    355     _palette.append(color)
    356 # Don't modify if nothing changed
--> 357 if _palette is not None and list(_palette) != list(adata.uns[color_key]):
    358     adata.uns[color_key] = _palette

ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()
```


### Versions

<details>

```
-----
anndata     0.10.3
scanpy      1.9.6
-----
PIL                 10.1.0
appnope             0.1.3
asttokens           NA
comm                0.1.4
cycler              0.12.1
cython_runtime      NA
dateutil            2.8.2
debugpy             1.8.0
decorator           5.1.1
executing           2.0.1
h5py                3.10.0
ipykernel           6.26.0
jedi                0.19.1
joblib              1.3.2
kiwisolver          1.4.5
llvmlite            0.41.1
matplotlib          3.8.1
matplotlib_inline   0.1.6
mpl_toolkits        NA
natsort             8.4.0
numba               0.58.1
numpy               1.26.1
packaging           23.2
pandas              2.1.1
parso               0.8.3
pexpect             4.8.0
pickleshare         0.7.5
platformdirs        3.11.0
prompt_toolkit      3.0.39
psutil              5.9.5
ptyprocess          0.7.0
pure_eval           0.2.2
pydev_ipython       NA
pydevconsole        NA
pydevd              2.9.5
pydevd_file_utils   NA
pydevd_plugins      NA
pydevd_tracing      NA
pygments            2.16.1
pyparsing           3.1.1
pytz                2023.3.post1
scipy               1.11.3
session_info        1.0.0
six                 1.16.0
sklearn             1.3.2
stack_data          0.6.2
threadpoolctl       3.2.0
tornado             6.3.3
tqdm                4.66.1
traitlets           5.13.0
typing_extensions   NA
wcwidth             0.2.9
zmq                 25.1.1
-----
IPython             8.17.2
jupyter_client      8.5.0
jupyter_core        5.5.0
-----
Python 3.11.6 | packaged by conda-forge | (main, Oct  3 2023, 10:40:37) [Clang 15.0.7 ]
macOS-14.0-x86_64-i386-64bit
-----
Session information updated at 2023-11-03 17:57
```

</details>


Discussion:

Patch:
diff --git a/docs/release-notes/1.9.7.md b/docs/release-notes/1.9.7.md
index e17ca27c..64a35a94 100644
--- a/docs/release-notes/1.9.7.md
+++ b/docs/release-notes/1.9.7.md
@@ -2,3 +2,4 @@
 
 ```{rubric} Bug fixes
 ```
+- Fix handling of numpy array palettes (e.g. after write-read cycle) {pr}`2734` {smaller}`P Angerer`
diff --git a/scanpy/plotting/_utils.py b/scanpy/plotting/_utils.py
index bbcd5978..3c9a8fec 100644
--- a/scanpy/plotting/_utils.py
+++ b/scanpy/plotting/_utils.py
@@ -331,7 +331,7 @@ def default_palette(
         return palette
 
 
-def _validate_palette(adata, key):
+def _validate_palette(adata: anndata.AnnData, key: str) -> None:
     """
     checks if the list of colors in adata.uns[f'{key}_colors'] is valid
     and updates the color list in adata.uns[f'{key}_colors'] if needed.
@@ -359,8 +359,9 @@ def _validate_palette(adata, key):
                 break
         _palette.append(color)
     # Don't modify if nothing changed
-    if _palette is not None and list(_palette) != list(adata.uns[color_key]):
-        adata.uns[color_key] = _palette
+    if _palette is None or np.equal(_palette, adata.uns[color_key]).all():
+        return
+    adata.uns[color_key] = _palette
 
 
 def _set_colors_for_categorical_obs(


Test patch:
diff --git a/scanpy/tests/test_plotting_utils.py b/scanpy/tests/test_plotting_utils.py
new file mode 100644
index 00000000..c1deff53
--- /dev/null
+++ b/scanpy/tests/test_plotting_utils.py
@@ -0,0 +1,28 @@
+from typing import cast
+import numpy as np
+import pytest
+
+from anndata import AnnData
+from matplotlib import colormaps
+from matplotlib.colors import ListedColormap
+
+from scanpy.plotting._utils import _validate_palette
+
+
+viridis = cast(ListedColormap, colormaps["viridis"])
+
+
+@pytest.mark.parametrize(
+    "palette",
+    [
+        pytest.param(viridis.colors, id="viridis"),
+        pytest.param(["b", "#cccccc", "r", "yellow", "lightblue"], id="named"),
+        pytest.param([(1, 0, 0, 1), (0, 0, 1, 1)], id="rgba"),
+    ],
+)
+@pytest.mark.parametrize("typ", [np.asarray, list])
+def test_validate_palette_no_mod(palette, typ):
+    palette = typ(palette)
+    adata = AnnData(uns=dict(test_colors=palette))
+    _validate_palette(adata, "test")
+    assert palette is adata.uns["test_colors"], "Palette should not be modified"