Repository: napari/napari

Issue:
Toggle point class property fails if they are all in the same class
## 🐛 Bug

When running the example for adding points with properties. If the 'good_point' property in the [line](https://github.com/napari/napari/blob/f53d7448f2858a36ff7a57bcfc5176018f2af670/examples/add_points_with_properties.py#L21) is homogeneous for all points (all 'True' or all 'False'), then the toggling fails. Is that expected behavior?



## To Reproduce

Steps to reproduce the behavior:

1. Set `good_point': np.array([True, True, True])`
2. Run the example
3. Select point, press 't' and nothing happens

## Expected behavior

1. Set `good_point': np.array([True, True, True])`
2. Run the example
3. Select point, press 't' the point should be toggling from green to blue

## Environment

 - Please copy and paste the information at napari info option in help menubar here:
napari: 0.2.11
Platform: Darwin-18.6.0-x86_64-i386-64bit
Python: 3.7.6 (default, Dec 30 2019, 19:38:28) [Clang 11.0.0 (clang-1100.0.33.16)]
Qt: 5.13.2
PySide2: 5.13.2
VisPy: 0.6.4
NumPy: 1.18.1
SciPy: 1.4.1
scikit-image: 0.16.2
Dask: 2.11.0

GL version: 2.1 ATI-2.9.26
MAX_TEXTURE_SIZE: 16384
 - Any other relevant information:

## Additional context

<!-- Add any other context about the problem here. -->


Discussion:
Hi @mirestrepo, thanks for raising this issue - this looks like a bug, or at least somewhat undesirable behaviour. I was able to reproduce it by changing the line of code you highlight in the example and then running it. When I select a point and hit `t` I get the following error message

```python-traceback
  File "/Users/nsofroniew/GitHub/vispy/vispy/app/backends/_qt.py", line 502, in keyPressEvent
    self._keyEvent(self._vispy_canvas.events.key_press, ev)
  File "/Users/nsofroniew/GitHub/vispy/vispy/app/backends/_qt.py", line 551, in _keyEvent
    func(native=ev, key=key, text=text_type(ev.text()), modifiers=mod)
  File "/Users/nsofroniew/GitHub/vispy/vispy/util/event.py", line 455, in __call__
    self._invoke_callback(cb, event)
  File "/Users/nsofroniew/GitHub/vispy/vispy/util/event.py", line 475, in _invoke_callback
    self, cb_event=(cb, event))
  File "/Users/nsofroniew/GitHub/vispy/vispy/util/event.py", line 471, in _invoke_callback
    cb(event)
  File "/Users/nsofroniew/GitHub/napari/napari/_qt/qt_viewer.py", line 505, in on_key_press
    gen = func(parent)
  File "examples/add_points_with_properties.py", line 55, in toggle_point_annotation
    points_layer.refresh_colors(update_color_mapping=False)
  File "/Users/nsofroniew/GitHub/napari/napari/layers/points/points.py", line 898, in refresh_colors
    for x in face_color_properties
  File "/Users/nsofroniew/GitHub/napari/napari/layers/points/points.py", line 898, in <listcomp>
    for x in face_color_properties
KeyError: False
```

It looks like because only `True` values were present at initialization when we then look for a `False` one we get an error, but I havn't dug deep into this yet. Maybe @kevinyamauchi can look into this too?
Hello, @mirestrepo . Thank you for posting a very clear issue!

As, @sofroniewn guessed, the issue here is that the colormap for the `good_point` property is set upon initialization (a color is only assigned for `True` in your example) and then when a value is toggled to `False`, there isn't a color available in the colormap.

The reason for this behavior is [this line](https://github.com/napari/napari/blob/f53d7448f2858a36ff7a57bcfc5176018f2af670/examples/add_points_with_properties.py#L55) where the colors are refreshed and colormap is not updated (i.e., the existing colormaps are used, see the `update_color_mapping=False` kwarg). We chose not to update the color map in this particular example to avoid the colormap changing when the `good_point` property values go from heterogeneous to homogeneous. However, as you have shown, this causes issues if you initialize with homogeneous `good_point` values. If you would like to update the colormap based on the current `good_point` values, you can set `update_color_mapping=True` in the `Points.refresh_colors()` call. Doing so would fix this issue, but I think it's probably better to check the color map and update as necessary (see below).

We could improve the example by checking the color map after toggling the `good_point` values and then updating the colormap when refreshing the colors if there are `good_point` values that are not present in the colormap (sample implementation below). @mirestrepo , I think this would be a great contribution if you'd like to make a pull request! Otherwise, I am happy to do it!

```python
    # bind a function to toggle the good_point annotation of the selected points
    @viewer.bind_key('t')
    def toggle_point_annotation(viewer):
        selected_points = points_layer.selected_data
        if len(selected_points) > 0:
            good_point = points_layer.properties['good_point']
            good_point[selected_points] = ~good_point[selected_points]
            points_layer.properties['good_point'] = good_point

            # we need to manually refresh since we did not use the Points.properties setter
            # iff there are new good_point_values, update the color cycle map (update_color_mapping=True)
            face_color_cycle_keys = [*points_layer.face_color_cycle_map]
            if np.all(np.in1d(good_point, face_color_cycle_keys)):
                points_layer.refresh_colors(update_color_mapping=False)
            else:
                points_layer.refresh_colors(update_color_mapping=True)
```
@kevinyamauchi thanks for the suggestion on a fix. Is there a way where we could do more within `napari` (and not within the example) to catch right before this error (i.e. `if x not in face_color_properties`) and then force the update of the color mapping when that happens? I worry the burden we're placing on the external developer / user here to remember this / code in this way might be too great.
@sofroniewn , I definitely agree we should try to do as much of this type of thing as possible from within napari to avoid user/dev burden. I had initially shied away from doing so here because I wasn't sure that we should update the color map when the user sets `update_color_mapping=False`. However, I can see that doing so could be preferable to throwing an error.

If we want to go that route, we could add a check when `update_color_map=False` to see if there are values not in the color map and if necessary, add them in a way that preserves the existing colormapping. For example, we could add the following case to where we update the color mapping ([here](https://github.com/napari/napari/blob/bd538488c9a6f9d6721cec8268610a1ea5b0c7d0/napari/layers/points/points.py#L887)). What do you all think?

```python
if update_color_mapping:
    self.face_color_cycle_map = {
        k: c
        for k, c in zip(
            np.unique(face_color_properties),
            self.face_color_cycle,
        )
    }
# add properties if they are not in the colormap and update_color_mapping==False
else:
    face_color_cycle_keys = [*self.face_color_cycle_map]
    props_in_map = np.in1d(face_color_properties, face_color_cycle_keys)
    if not np.all(props_in_map):
        props_to_add = np.unique(face_color_properties[np.logical_not(props_in_map)])
        for prop in props_to_add:
            self.face_color_cycle_map[prop] = next(self.face_color_cycle)

```
Thanks @kevinyamauchi! I don't have time to do a proper review of this right now - in a microscopy software acquisition workshop at janelia right now :-) but this looks good at first glance. I will try and take a closer look later
I like your solution, @kevinyamauchi. Bonus points, I feel like we can leave `update_color_map` as default False now!
Thanks for the feedback, @jni and @sofroniewn. Sorry for the late response! Things with the move are a bit hectic right now.

@mirestrepo , I am sorry about the false start with the example modification I suggested. It sounds like there is consensus around the change to `Points.refresh_colors()` that I [suggested above](https://github.com/napari/napari/issues/988#issuecomment-589078718). I can work on this sometime next week. However, if you would like to implement it and make a PR (I am happy to help as much or as little as you'd like), I think that would be great too!

Resolving patch:
diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py
--- a/napari/layers/points/points.py
+++ b/napari/layers/points/points.py
@@ -321,6 +321,7 @@ def __init__(
             if edge_color_cycle is None:
                 edge_color_cycle = DEFAULT_COLOR_CYCLE
             self.edge_color_cycle = edge_color_cycle
+            self.edge_color_cycle_map = {}
             self.edge_colormap = edge_colormap
             self._edge_contrast_limits = edge_contrast_limits
 
@@ -329,6 +330,7 @@ def __init__(
             if face_color_cycle is None:
                 face_color_cycle = DEFAULT_COLOR_CYCLE
             self.face_color_cycle = face_color_cycle
+            self.face_color_cycle_map = {}
             self.face_colormap = face_colormap
             self._face_contrast_limits = face_contrast_limits
 
@@ -621,7 +623,7 @@ def edge_color_cycle(
             default="white",
         )
         if self._edge_color_mode == ColorMode.CYCLE:
-            self.refresh_colors()
+            self.refresh_colors(update_color_mapping=True)
 
     @property
     def edge_colormap(self):
@@ -762,7 +764,7 @@ def face_color_cycle(self, face_color_cycle: Union[np.ndarray, cycle]):
             default="white",
         )
         if self._face_color_mode == ColorMode.CYCLE:
-            self.refresh_colors()
+            self.refresh_colors(update_color_mapping=True)
 
     @property
     def face_colormap(self):
@@ -861,7 +863,7 @@ def face_color_mode(self, face_color_mode):
             self._face_color_mode = face_color_mode
             self.refresh_colors()
 
-    def refresh_colors(self, update_color_mapping: bool = True):
+    def refresh_colors(self, update_color_mapping: bool = False):
         """Calculate and update face and edge colors if using a cycle or color map
 
         Parameters
@@ -888,6 +890,22 @@ def refresh_colors(self, update_color_mapping: bool = True):
                             self.face_color_cycle,
                         )
                     }
+
+                else:
+                    # add properties if they are not in the colormap
+                    # and update_color_mapping==False
+                    face_color_cycle_keys = [*self.face_color_cycle_map]
+                    props_in_map = np.in1d(
+                        face_color_properties, face_color_cycle_keys
+                    )
+                    if not np.all(props_in_map):
+                        props_to_add = np.unique(
+                            face_color_properties[np.logical_not(props_in_map)]
+                        )
+                        for prop in props_to_add:
+                            self.face_color_cycle_map[prop] = next(
+                                self.face_color_cycle
+                            )
                 face_colors = np.array(
                     [
                         self.face_color_cycle_map[x]
@@ -901,7 +919,7 @@ def refresh_colors(self, update_color_mapping: bool = True):
                 face_color_properties = self.properties[
                     self._face_color_property
                 ]
-                if update_color_mapping:
+                if update_color_mapping or self.face_contrast_limits is None:
                     face_colors, contrast_limits = map_property(
                         prop=face_color_properties,
                         colormap=self.face_colormap[1],
@@ -927,6 +945,21 @@ def refresh_colors(self, update_color_mapping: bool = True):
                             self.edge_color_cycle,
                         )
                     }
+                else:
+                    # add properties if they are not in the colormap
+                    # and update_color_mapping==False
+                    edge_color_cycle_keys = [*self.edge_color_cycle_map]
+                    props_in_map = np.in1d(
+                        edge_color_properties, edge_color_cycle_keys
+                    )
+                    if not np.all(props_in_map):
+                        props_to_add = np.unique(
+                            edge_color_properties[np.logical_not(props_in_map)]
+                        )
+                        for prop in props_to_add:
+                            self.edge_color_cycle_map[prop] = next(
+                                self.edge_color_cycle
+                            )
                 edge_colors = np.array(
                     [
                         self.edge_color_cycle_map[x]
@@ -938,7 +971,7 @@ def refresh_colors(self, update_color_mapping: bool = True):
                 edge_color_properties = self.properties[
                     self._edge_color_property
                 ]
-                if update_color_mapping:
+                if update_color_mapping or self.edge_contrast_limits is None:
                     edge_colors, contrast_limits = map_property(
                         prop=edge_color_properties,
                         colormap=self.edge_colormap[1],


Test patch:
diff --git a/napari/layers/points/_tests/test_points.py b/napari/layers/points/_tests/test_points.py
--- a/napari/layers/points/_tests/test_points.py
+++ b/napari/layers/points/_tests/test_points.py
@@ -598,6 +598,35 @@ def test_edge_color_cycle():
     )
 
 
+def test_adding_value_edge_color_cycle():
+    """ Test that adding values to properties used to set an edge color cycle
+    and then calling Points.refresh_colors() performs the update and adds the
+    new value to the edge_color_cycle_map.
+
+    See: https://github.com/napari/napari/issues/988
+    """
+    shape = (10, 2)
+    np.random.seed(0)
+    data = 20 * np.random.random(shape)
+    annotations = {'point_type': np.array(['A', 'B'] * int((shape[0] / 2)))}
+    color_cycle = ['red', 'blue']
+    layer = Points(
+        data,
+        properties=annotations,
+        edge_color='point_type',
+        edge_color_cycle=color_cycle,
+    )
+
+    # make point 0 point_type C
+    point_types = layer.properties['point_type']
+    point_types[0] = 'C'
+    layer.properties['point_type'] = point_types
+    layer.refresh_colors(update_color_mapping=False)
+
+    edge_color_map_keys = [*layer.edge_color_cycle_map]
+    assert 'C' in edge_color_map_keys
+
+
 def test_edge_color_colormap():
     # create Points using with face_color colormap
     shape = (10, 2)
@@ -770,6 +799,35 @@ def test_face_color_cycle():
     )
 
 
+def test_adding_value_face_color_cycle():
+    """ Test that adding values to properties used to set an face color cycle
+    and then calling Points.refresh_colors() performs the update and adds the
+    new value to the face_color_cycle_map.
+
+    See: https://github.com/napari/napari/issues/988
+    """
+    shape = (10, 2)
+    np.random.seed(0)
+    data = 20 * np.random.random(shape)
+    annotations = {'point_type': np.array(['A', 'B'] * int((shape[0] / 2)))}
+    color_cycle = ['red', 'blue']
+    layer = Points(
+        data,
+        properties=annotations,
+        face_color='point_type',
+        face_color_cycle=color_cycle,
+    )
+
+    # make point 0 point_type C
+    point_types = layer.properties['point_type']
+    point_types[0] = 'C'
+    layer.properties['point_type'] = point_types
+    layer.refresh_colors(update_color_mapping=False)
+
+    face_color_map_keys = [*layer.face_color_cycle_map]
+    assert 'C' in face_color_map_keys
+
+
 def test_face_color_colormap():
     # create Points using with face_color colormap
     shape = (10, 2)