Repository: napari/napari

Issue:
Creating a labels layer from the layer `data_tuple` results in empty colors.
## 🐛 Bug

Copying labels through the `data_tuple` API results in a new Labels layers with empty colors.
I'm not sure if this a real bug because I'm using some "private" methods.

## To Reproduce

```python3
import napari
import numpy as np

viewer = napari.Viewer()
labels = viewer.add_labels(np.random.randint(10, size=(10, 10)))

data_tuple = labels.as_layer_data_tuple()
cpy = viewer._add_layer_from_data(*data_tuple)[0]

# cpy.color_mode = labels.color_mode  # with this line it works                                                                                                                                                                                          

napari.run()
```

## Expected behavior

The new layer should be equal to its source, including the color mapping.

## Environment

```
napari: 0.4.8
Platform: Linux-5.8.0-55-generic-x86_64-with-glibc2.10
System: Ubuntu 20.04.2 LTS
Python: 3.8.8 (default, Apr 13 2021, 19:58:26) [GCC 7.3.0]
Qt: 5.15.2
PyQt5: 5.15.4
NumPy: 1.20.3
SciPy: 1.6.3
Dask: 2021.04.0
VisPy: 0.6.6

OpenGL:
- GL version: 4.6 (Compatibility Profile) Mesa 20.2.6
- MAX_TEXTURE_SIZE: 16384

Screens:
- screen 1: resolution 3440x1440, scale 1.0

Plugins:
- console: 0.0.3
- napari-dexp: 0.1.dev7+g3714459
- napari-simple-crop: 0.1.dev1+gd89c7ea.d20210629
- ome_zarr: 0.0.19
- scikit-image: 0.4.8
- svg: 0.1.4
```

## Additional context

I thought this could be solved by adding this attribute to the [_get_state](https://github.com/napari/napari/blob/d310847f0c79f062daadecb8a159e8972c074470/napari/layers/labels/labels.py#L514) but it does not work with the `_add_layer_from_data` since it is not an argument of the `add_labels` method.



Discussion:
that's a bug,
it appears that the default `color_mode` default really depends on whether the `color` argument is provided or not (i.e.  `color_mode` is _also_ set in `@color.setter`)

```python
In [53]: labels = Labels(np.zeros((1,1), int))

In [54]: labels.color_mode
Out[54]: 'auto'

In [55]: Labels(labels.data, color=labels.color).color_mode
Out[55]: 'direct'
```

cc @andy-sweet
> I'm not sure if this a real bug because I'm using some "private" methods.

by the way... `viewer._add_layer_from_data(*data_tuple)` is a very important private method, that may actually be public someday soon (see https://github.com/napari/napari/pull/2556/files#r659247491).  so this is one "private" usage that should definitely work.

Do you think the `color_mode` default is the issue?

I think that the `color` key from `_get_state` dict returning a partially complete dict is the problematic behavior. I think it should return the complete dict if `color_mode` is `direct`, else it should return an empty dict. 

We could remove the `None` and `_background_label` values from the `color` key in `_get_state()`  dict because they are added if not available, but it might lead to other problems.

> Do you think the color_mode default is the issue?

I'm not so much trying to state that the color_mode default itself is the problem, just that the round trip is not consistent due to the way `color_mode` is initialized. (which I think is what you're saying too).    You could either have the color.setter recognize that the provided `color` argument is the default, set color_mode to auto... or you could strip the default values from `_get_state()`... don't have a strong preference for the approach

Resolving patch:
diff --git a/napari/layers/labels/labels.py b/napari/layers/labels/labels.py
--- a/napari/layers/labels/labels.py
+++ b/napari/layers/labels/labels.py
@@ -460,16 +460,11 @@ def color(self):
 
     @color.setter
     def color(self, color):
-
         if not color:
             color = {}
-            color_mode = LabelColorMode.AUTO
-        else:
-            color_mode = LabelColorMode.DIRECT
 
         if self._background_label not in color:
             color[self._background_label] = 'transparent'
-
         if None not in color:
             color[None] = 'black'
 
@@ -477,10 +472,55 @@ def color(self, color):
             label: transform_color(color_str)[0]
             for label, color_str in color.items()
         }
-
         self._color = colors
+
+        # `colors` may contain just the default None and background label
+        # colors, in which case we need to be in AUTO color mode. Otherwise,
+        # `colors` contains colors for all labels, and we should be in DIRECT
+        # mode.
+
+        # For more information
+        # - https://github.com/napari/napari/issues/2479
+        # - https://github.com/napari/napari/issues/2953
+        if self._is_default_colors(colors):
+            color_mode = LabelColorMode.AUTO
+        else:
+            color_mode = LabelColorMode.DIRECT
+
         self.color_mode = color_mode
 
+    def _is_default_colors(self, color):
+        """Returns True if color contains only default colors, otherwise False.
+
+        Default colors are black for `None` and transparent for
+        `self._background_label`.
+
+        Parameters
+        ----------
+        color : Dict
+            Dictionary of label value to color array
+
+        Returns
+        -------
+        bool
+            True if color contains only default colors, otherwise False.
+        """
+        if len(color) != 2:
+            return False
+
+        if not hasattr(self, '_color'):
+            return False
+
+        default_keys = [None, self._background_label]
+        if set(default_keys) != set(color.keys()):
+            return False
+
+        for key in default_keys:
+            if not np.allclose(self._color[key], color[key]):
+                return False
+
+        return True
+
     def _ensure_int_labels(self, data):
         """Ensure data is integer by converting from bool if required, raising an error otherwise."""
         looks_multiscale, data = guess_multiscale(data)


Test patch:
diff --git a/napari/layers/labels/_tests/test_labels.py b/napari/layers/labels/_tests/test_labels.py
--- a/napari/layers/labels/_tests/test_labels.py
+++ b/napari/layers/labels/_tests/test_labels.py
@@ -1264,3 +1264,35 @@ def test_labels_state_update():
     state = layer._get_state()
     for k, v in state.items():
         setattr(layer, k, v)
+
+
+def test_is_default_color():
+    """Test labels layer default color for None and background
+
+    Previously, setting color to just default values would
+    change color mode to DIRECT and display a black layer.
+    This test ensures `is_default_color` is
+    correctly checking against layer defaults, and `color_mode`
+    is only changed when appropriate.
+
+    See
+        - https://github.com/napari/napari/issues/2479
+        - https://github.com/napari/napari/issues/2953
+    """
+    data = np.random.randint(20, size=(10, 15))
+    layer = Labels(data)
+
+    # layer gets instantiated with defaults
+    current_color = layer.color
+    assert layer._is_default_colors(current_color)
+
+    # setting color to default colors doesn't update color mode
+    layer.color = current_color
+    assert layer.color_mode == 'auto'
+
+    # new colors are not default
+    new_color = {0: 'white', 1: 'red', 3: 'green'}
+    assert not layer._is_default_colors(new_color)
+    # setting the color with non-default colors updates color mode
+    layer.color = new_color
+    assert layer.color_mode == 'direct'