Repository: napari/napari

Issue:
Text on shapes layer fails when ndim>3
## 🐛 Bug

Reported on image.sc [here](https://forum.image.sc/t/3d-bounding-box-text-labels-valueerror-pos/49369)

When adding a shapes layer with text properties to a 3D+t dataset, the text update fails with the traceback

<details>

<summary>

```pytb
  File "/home/jni/miniconda3/envs/all/lib/python3.8/site-packages/vispy/visuals/text/text.py", line 509, in pos
    raise ValueError('pos must have 2 or 3 elements')
ValueError: pos must have 2 or 3 elements
```

</summary>

```pytb
Traceback (most recent call last):
  File "/home/jni/projects/napari/napari/_qt/widgets/qt_dims_slider.py", line 121, in _value_changed
    self.dims.set_current_step(self.axis, value)
  File "/home/jni/projects/napari/napari/components/dims.py", line 236, in set_current_step
    self.current_step = full_current_step
  File "/home/jni/projects/napari/napari/utils/events/evented_model.py", line 145, in __setattr__
    getattr(self.events, name)(value=after)  # emit event
  File "/home/jni/projects/napari/napari/utils/events/event.py", line 550, in __call__
    self._invoke_callback(cb, event)
  File "/home/jni/projects/napari/napari/utils/events/event.py", line 567, in _invoke_callback
    _handle_exception(
  File "/home/jni/projects/napari/napari/utils/events/event.py", line 565, in _invoke_callback
    cb(event)
  File "/home/jni/projects/napari/napari/components/viewer_model.py", line 252, in _update_layers
    layer._slice_dims(
  File "/home/jni/projects/napari/napari/layers/base/base.py", line 865, in _slice_dims
    self._update_dims()
  File "/home/jni/projects/napari/napari/layers/base/base.py", line 530, in _update_dims
    self.refresh()
  File "/home/jni/projects/napari/napari/layers/base/base.py", line 941, in refresh
    self.events.set_data()
  File "/home/jni/projects/napari/napari/utils/events/event.py", line 550, in __call__
    self._invoke_callback(cb, event)
  File "/home/jni/projects/napari/napari/utils/events/event.py", line 567, in _invoke_callback
    _handle_exception(
  File "/home/jni/projects/napari/napari/utils/events/event.py", line 565, in _invoke_callback
    cb(event)
  File "/home/jni/projects/napari/napari/_vispy/vispy_shapes_layer.py", line 57, in _on_data_change
    self._on_text_change(update_node=False)
  File "/home/jni/projects/napari/napari/_vispy/vispy_shapes_layer.py", line 128, in _on_text_change
    update_text(
  File "/home/jni/projects/napari/napari/_vispy/_text_utils.py", line 55, in update_text
    text_node.pos = positions
  File "/home/jni/miniconda3/envs/all/lib/python3.8/site-packages/vispy/util/frozen.py", line 17, in __setattr__
    object.__setattr__(self, key, value)
  File "/home/jni/miniconda3/envs/all/lib/python3.8/site-packages/vispy/visuals/text/text.py", line 509, in pos
    raise ValueError('pos must have 2 or 3 elements')
ValueError: pos must have 2 or 3 elements

```

</details>

## To Reproduce

Steps to reproduce the behavior:

```python
import numpy as np
import napari


# 3D images for 20 timesteps
test_img_stack = np.zeros((20,25,500,500))

# create corners sort of centered in the middle of image    
corners = np.random.uniform(100, 300, size=(100, 4, 2))

# time component same for all coordinates
t = np.random.randint(0, 20, size=(100, 1, 1))
t = np.repeat(t, repeats=4, axis=1)

# z coordinate same for all corner points
z = np.random.randint(0, 25, size=(100,1,1))
z= np.repeat(z, repeats=4, axis=1)

shapes = np.concatenate((t, z, corners), axis=2)
    
# one label per box
labels = np.random.randint(0, 20, (100))

# specify properties and text
properties = {
        'label': labels
    }

text_parameters = {
        'text': 'label: {label}',
        'size': 4,
        'color': 'green',
        'anchor': 'center',
    }


with napari.gui_qt():
        viewer = napari.Viewer()
        viewer.add_shapes(shapes, shape_type='polygon', face_color='transparent',
                     edge_color='green',name='Box', blending='additive',
                     properties=properties, text=text_parameters)
        viewer.add_image(test_img_stack, name='Image', colormap='gray', blending='additive')
```

## Expected behavior

Text should have correct placement in 2D/3D and be correctly sliced.

## Environment

```
napari: 0.4.4.dev89+ge33c2fd1
Platform: Linux-4.15.0-29-generic-x86_64-with-glibc2.10
System: Ubuntu 18.04.5 LTS
Python: 3.8.2 (default, Mar 26 2020, 15:53:00)  [GCC 7.3.0]
Qt: 5.14.2
PyQt5: 5.14.2
NumPy: 1.19.0rc1
SciPy: 1.5.4
Dask: 2.26.0
VisPy: 0.6.4

OpenGL:
  - GL version:  3.0 Mesa 19.2.8
  - MAX_TEXTURE_SIZE: 16384

Screens:
  - screen 1: resolution 2560x1440, scale 1.0

Plugins:
  - affinder: 0.1.dev15+g0b87958.d20210122
  - console: 0.0.3
  - napari-sentinel-zip: 0.2.2
  - nd2-reader: 0.0.4
  - ndtiffs: 0.1.1
  - svg: 0.1.4
  - tracks: 0.0.2

```

 - Any other relevant information:

## Additional context

<!-- Add any other context about the problem here. -->



Discussion:
This issue has been mentioned on **Image.sc Forum**. There might be relevant details there:

https://forum.image.sc/t/3d-bounding-box-text-labels-valueerror-pos/49369/4

Ah, sorry about that! I agree that we likely aren't correctly slicing the coords for n>3 D. Unfortunately, I don't have time to push a fix this week, but I've outlined what I think needs to be done below.

The coordinates of the next elements are passed to vispy via `layer._view_text_coords`, which calls the `TextManager.compute_text_coords()` method to get the coordinates. `TextManager.compute_text_coords()` calculates the coordsinates for the text elements based on the layer data in view and number of dimensions being displayed. I think the bug is in the implementation of this for the shapes layer (see [here](https://github.com/napari/napari/blob/3d8f315a74ce53d2e6a784f246b5d405d3951667/napari/layers/shapes/shapes.py#L1292-L1302)). We are passing the coordinates of the vertices of _all_ shapes. I think this should be modified such that:

1. we select the coordinates of the shapes in view. I believe the in view shapes are stored in `Shapes._data_view._displayed` (see [here ](https://github.com/napari/napari/blob/3d8f315a74ce53d2e6a784f246b5d405d3951667/napari/layers/shapes/_shape_list.py#L881 )for an example).
2. we slices the coordinates to get the ones that are displayed in the canvas. See [here ](https://github.com/napari/napari/blob/3d8f315a74ce53d2e6a784f246b5d405d3951667/napari/layers/points/points.py#L1395)for how we do it with the points coordinates. We won't be able too use fancy indexing in the Shapes layer since the coordinates of the vertices are stored in a list (to accommodate different numbers of vertices per shape).

Something like:


```python
    def _view_text_coords(self) -> np.ndarray:
        """Get the coordinates of the text elements in view
        Returns
        -------
        text_coords : (N x D) np.ndarray
            Array of coordindates for the N text elements in view
        """
        # get the coordinates of the vertices for the shapes in view
        all_shapes_coords = self._data_view.data
        in_view_shapes = all_shapes_coords [self._data_view._displayed]
        in_view_shapes_coords = all_shapes_coords[in_view_shapes]

        # get the coordinates for the dimensions being displayed
        sliced_shapes_coords = [func_to_slice_coords(c, self._indices_view) for c in in_view_shapes_coords)]

        return self.text.compute_text_coords(
            sliced_shapes_coords,  self._ndisplay
        )
```

Resolving patch:
diff --git a/napari/layers/shapes/shapes.py b/napari/layers/shapes/shapes.py
--- a/napari/layers/shapes/shapes.py
+++ b/napari/layers/shapes/shapes.py
@@ -1290,8 +1290,19 @@ def _view_text_coords(self) -> np.ndarray:
         text_coords : (N x D) np.ndarray
             Array of coordindates for the N text elements in view
         """
+        # get the coordinates of the vertices for the shapes in view
+        in_view_shapes_coords = [
+            self._data_view.data[i] for i in self._indices_view
+        ]
+
+        # get the coordinates for the dimensions being displayed
+        sliced_in_view_coords = [
+            position[:, self._dims_displayed]
+            for position in in_view_shapes_coords
+        ]
+
         return self.text.compute_text_coords(
-            self._data_view.data, self._ndisplay
+            sliced_in_view_coords, self._ndisplay
         )
 
     @property


Test patch:
diff --git a/napari/layers/shapes/_tests/test_shapes.py b/napari/layers/shapes/_tests/test_shapes.py
--- a/napari/layers/shapes/_tests/test_shapes.py
+++ b/napari/layers/shapes/_tests/test_shapes.py
@@ -253,6 +253,26 @@ def test_refresh_text():
     np.testing.assert_equal(layer.text.values, new_properties['shape_type'])
 
 
+def test_nd_text():
+    """Test slicing of text coords with nD shapes"""
+    shapes_data = [
+        [[0, 10, 10, 10], [0, 10, 20, 20], [0, 10, 10, 20], [0, 10, 20, 10]],
+        [[1, 20, 30, 30], [1, 20, 50, 50], [1, 20, 50, 30], [1, 20, 30, 50]],
+    ]
+    properties = {'shape_type': ['A', 'B']}
+    text_kwargs = {'text': 'shape_type', 'anchor': 'center'}
+    layer = Shapes(shapes_data, properties=properties, text=text_kwargs)
+    assert layer.ndim == 4
+
+    layer._slice_dims(point=[0, 10, 0, 0], ndisplay=2)
+    np.testing.assert_equal(layer._indices_view, [0])
+    np.testing.assert_equal(layer._view_text_coords[0], [[15, 15]])
+
+    layer._slice_dims(point=[1, 0, 0, 0], ndisplay=3)
+    np.testing.assert_equal(layer._indices_view, [1])
+    np.testing.assert_equal(layer._view_text_coords[0], [[20, 40, 40]])
+
+
 @pytest.mark.parametrize("properties", [properties_array, properties_list])
 def test_data_setter_with_text(properties):
     """Test layer data on a layer with text via the data setter"""