Repository: napari/napari

Issue:
Broken slicing when layers are added with visible=False onto a 3D canvas
### 🐛 Bug Report

If you add a Labels layer when the viewer is in 3D view with `visible=False`, the slice information will be incorrect, and it will not be updated when setting `visible=True` on the layer. This will result in an incorrect display (a 2D slice of data in the 3D space). Additionally, the 2D slice will be used when using get_value which results in the following traceback getting spammed to the terminal/error notifications:

<details>

<summary>

```pytb
File ~/projects/napari/napari/layers/labels/labels.py:1000, in Labels._get_value_ray(self=<Labels layer 'labels'>, start_point=<class 'numpy.ndarray'> (3,) float64, end_point=<class 'numpy.ndarray'> (3,) float64, dims_displayed=[0, 1, 2])
-> 1000 values = im_slice[tuple(clamped.T)]
IndexError: too many indices for array: array is 2-dimensional, but 3 were indexed
```

</summary>

```pytb
IndexError                                Traceback (most recent call last)
File ~/projects/napari/napari/components/viewer_model.py:543, in ViewerModel._update_status_bar_from_cursor(self=Viewer(camera=Camera(center=(2.0, 49.5, 49.5), z...use_drag_gen={}, _mouse_wheel_gen={}, _keymap={}), event=None)
    541 active = self.layers.selection.active
    542 if active is not None:
--> 543     self.status = active.get_status(
        active = <Labels layer 'labels' at 0x1651d2450>
        self.status = {'layer_base': 'labels', 'source_type': '', 'plugin': '', 'coordinates': ' [30 53 40]: 0'}
        self = Viewer(camera=Camera(center=(2.0, 49.5, 49.5), zoom=8.835, angles=(-39.99999999999999, -50.00000000000001, 149.99999999999997), perspective=0.0, mouse_pan=True, mouse_zoom=True), cursor=Cursor(position=(9.988404051331976, 50.24342051230364, 46.688696558469374), scaled=True, style=<CursorStyle.STANDARD: 'standard'>, size=10.0), dims=Dims(ndim=3, ndisplay=3, order=(0, 1, 2), axis_labels=('0', '1', '2'), range=(RangeTuple(start=0.0, stop=4.0, step=1.0), RangeTuple(start=0.0, stop=99.0, step=1.0), RangeTuple(start=0.0, stop=99.0, step=1.0)), margin_left=(0.0, 0.0, 0.0), margin_right=(0.0, 0.0, 0.0), point=(2.0, 49.0, 49.0), last_used=0), grid=GridCanvas(stride=1, shape=(-1, -1), enabled=False), layers=[<Image layer 'image' at 0x16cbbf050>, <Labels layer 'labels' at 0x1651d2450>], help='use <7> for transform, use <1> for activate the label eraser, use <2> for activate the paint brush, use <3> for activate the polygon tool, use <4> for activate the fill bucket, use <5> for pick mode', status={'layer_base': 'labels', 'source_type': '', 'plugin': '', 'coordinates': ' [30 53 40]: 0'}, tooltip=Tooltip(visible=False, text=''), theme='dark', title='napari', mouse_over_canvas=True, mouse_move_callbacks=[], mouse_drag_callbacks=[], mouse_double_click_callbacks=[], mouse_wheel_callbacks=[<function dims_scroll at 0x153e84360>], _persisted_mouse_event={}, _mouse_drag_gen={}, _mouse_wheel_gen={}, _keymap={})
        self.cursor.position = (9.988404051331976, 50.24342051230364, 46.688696558469374)
        self.cursor = Cursor(position=(9.988404051331976, 50.24342051230364, 46.688696558469374), scaled=True, style=<CursorStyle.STANDARD: 'standard'>, size=10.0)
        self.cursor._view_direction = <class 'numpy.ndarray'> (3,) float64
        self.dims = Dims(ndim=3, ndisplay=3, order=(0, 1, 2), axis_labels=('0', '1', '2'), range=(RangeTuple(start=0.0, stop=4.0, step=1.0), RangeTuple(start=0.0, stop=99.0, step=1.0), RangeTuple(start=0.0, stop=99.0, step=1.0)), margin_left=(0.0, 0.0, 0.0), margin_right=(0.0, 0.0, 0.0), point=(2.0, 49.0, 49.0), last_used=0)
    544         self.cursor.position,
    545         view_direction=self.cursor._view_direction,
    546         dims_displayed=list(self.dims.displayed),
    547         world=True,
    548     )
    550     self.help = active.help
    551     if self.tooltip.visible:

File ~/projects/napari/napari/layers/labels/labels.py:1532, in Labels.get_status(self=<Labels layer 'labels'>, position=(9.988404051331976, 50.24342051230364, 46.688696558469374), view_direction=<class 'numpy.ndarray'> (3,) float64, dims_displayed=[0, 1, 2], world=True)
   1510 """Status message information of the data at a coordinate position.
   1511
   1512 Parameters
   (...)
   1529     Dict containing a information that can be used in a status update.
   1530 """
   1531 if position is not None:
-> 1532     value = self.get_value(
        self = <Labels layer 'labels' at 0x1651d2450>
        position = (9.988404051331976, 50.24342051230364, 46.688696558469374)
        view_direction = <class 'numpy.ndarray'> (3,) float64
        dims_displayed = [0, 1, 2]
        world = True
   1533         position,
   1534         view_direction=view_direction,
   1535         dims_displayed=dims_displayed,
   1536         world=world,
   1537     )
   1538 else:
   1539     value = None

File ~/projects/napari/napari/layers/base/base.py:1317, in Layer.get_value(self=<Labels layer 'labels'>, position=<class 'numpy.ndarray'> (3,) float64, view_direction=<class 'numpy.ndarray'> (3,) float64, dims_displayed=[0, 1, 2], world=True)
   1310         view_direction = self._world_to_data_ray(view_direction)
   1311         start_point, end_point = self.get_ray_intersections(
   1312             position=position,
   1313             view_direction=view_direction,
   1314             dims_displayed=dims_displayed,
   1315             world=False,
   1316         )
-> 1317         value = self._get_value_3d(
        self = <Labels layer 'labels' at 0x1651d2450>
        start_point = <class 'numpy.ndarray'> (3,) float64
        end_point = <class 'numpy.ndarray'> (3,) float64
        dims_displayed = [0, 1, 2]
   1318             start_point=start_point,
   1319             end_point=end_point,
   1320             dims_displayed=dims_displayed,
   1321         )
   1322 else:
   1323     value = self._get_value(position)

File ~/projects/napari/napari/layers/labels/labels.py:1032, in Labels._get_value_3d(self=<Labels layer 'labels'>, start_point=<class 'numpy.ndarray'> (3,) float64, end_point=<class 'numpy.ndarray'> (3,) float64, dims_displayed=[0, 1, 2])
   1008 def _get_value_3d(
   1009     self,
   1010     start_point: Optional[np.ndarray],
   1011     end_point: Optional[np.ndarray],
   1012     dims_displayed: List[int],
   1013 ) -> Optional[int]:
   1014     """Get the first non-background value encountered along a ray.
   1015
   1016     Parameters
   (...)
   1029         non-zero value is not encountered, returns 0 (the background value).
   1030     """
   1031     return (
-> 1032         self._get_value_ray(
        self = <Labels layer 'labels' at 0x1651d2450>
        start_point = <class 'numpy.ndarray'> (3,) float64
        end_point = <class 'numpy.ndarray'> (3,) float64
        dims_displayed = [0, 1, 2]
   1033             start_point=start_point,
   1034             end_point=end_point,
   1035             dims_displayed=dims_displayed,
   1036         )
   1037         or 0
   1038     )

File ~/projects/napari/napari/layers/labels/labels.py:1000, in Labels._get_value_ray(self=<Labels layer 'labels'>, start_point=<class 'numpy.ndarray'> (3,) float64, end_point=<class 'numpy.ndarray'> (3,) float64, dims_displayed=[0, 1, 2])
    994 bounding_box[:, 1] += 1
    996 clamped = clamp_point_to_bounding_box(
    997     sample_points,
    998     bounding_box,
    999 ).astype(int)
-> 1000 values = im_slice[tuple(clamped.T)]
        im_slice = <class 'numpy.ndarray'> (100, 100) int32
        clamped = <class 'numpy.ndarray'> (31, 3) int64
   1001 nonzero_indices = np.flatnonzero(values)
   1002 if len(nonzero_indices > 0):
   1003     # if a nonzer0 value was found, return the first one

IndexError: too many indices for array: array is 2-dimensional, but 3 were indexed
```

</details>


### 💡 Steps to Reproduce

Here is a fully reproducible example:

```python
import numpy as np
import napari
from scipy import ndimage as ndi

image = np.random.random((5, 100, 100))
labels = ndi.label(image > 0.5)[0]

viewer, imlayer = napari.imshow(image, ndisplay=3)
labels_layer = viewer.add_labels(labels, visible=False)

labels_layer.visible = True

viewer.camera.angles = (-40, -50, 150)

napari.run()
```

Results in:

<img width="1337" alt="Screenshot 2024-02-08 at 6 16 54 pm" src="https://github.com/napari/napari/assets/492549/76ad7a63-bf7d-4082-a23a-c383721eded6">


### 💡 Expected Behavior

When visible is set to true **for the first time**, the slicing information should be updated and the layer should be sliced. (I expect that we don't currently do it so that toggling the visible button is fast and doesn't cause a potentially-expensive reslice every time the visibility is toggled.)

### 🌎 Environment

napari: 0.5.0a2.dev499+g1c055c54f
Platform: macOS-13.6.4-arm64-arm-64bit
System: MacOS 13.6.4
Python: 3.11.6 | packaged by conda-forge | (main, Oct  3 2023, 10:37:07) [Clang 15.0.7 ]
Qt: 5.15.11
PyQt5: 5.15.10
NumPy: 1.26.2
SciPy: 1.11.4
Dask: 2023.12.0
VisPy: 0.14.1
magicgui: 0.8.1
superqt: 0.6.1
in-n-out: 0.1.9
app-model: 0.2.2
npe2: 0.7.3

OpenGL:
  - GL version:  2.1 Metal - 83.1
  - MAX_TEXTURE_SIZE: 16384

Screens:
  - screen 1: resolution 3200x1800, scale 2.0
  - screen 2: resolution 1800x1169, scale 2.0

Settings path:
  - /Users/jni/Library/Application Support/napari/all_f206df4881e2999baa22e6df448393db299c70d6/settings.yaml
Plugins:
  - cellpose-napari: 0.1.5 (6 contributions)
  - iterseg: 0.1.0 (14 contributions)
  - napari: 0.5.0a2.dev730+g04eb761f1.d20231208 (81 contributions)
  - napari-animation: 0.0.7 (2 contributions)
  - napari-console: 0.0.9 (0 contributions)
  - napari-ome-zarr: 0.5.2 (2 contributions)
  - napari-svg: 0.1.11.dev1+g2542e94.d20231211 (2 contributions)
  - ome-types: 0.4.5 (2 contributions)
  - platetrack: 0.0.5 (2 contributions)

### 💡 Additional Context

Distilled down from this image.sc post:

https://forum.image.sc/t/cellpose-napri-produces-what-looks-like-an-orthogonal-label-layer/91900/7


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

https://forum.image.sc/t/cellpose-napri-produces-what-looks-like-an-orthogonal-label-layer/91900/8

As I remeber the same problem happens during 2D Rendering. 
Just in case, I gave this a check and seems like if you have the async experimental setting on no error is raised 🤔

![imagen](https://github.com/napari/napari/assets/16781833/4c08034e-555c-4433-94a6-b2d567f98738)

As a naive approach, emitting the layer reload event regardless of the async config value in the refresh layer method enables running the issue example code without raising any error:

So changing https://github.com/napari/napari/blob/4f4c063ae5dd79d6d188e201d44b8d57eba71909/napari/layers/base/base.py#L1433-L1444 for something like:

```python
    def refresh(self, event=None):
        """Refresh all layer data based on current view slice."""
        if self._refresh_blocked:
            logger.debug('Layer.refresh blocked: %s', self)
            return
        logger.debug('Layer.refresh: %s', self)
        self.events.reload(layer=self)
        if not get_settings().experimental.async_:
            # Slice immediately on the calling thread when not using async config.
            self._refresh_sync()
```

I got the following behavior (even when the experimental async config is disabled):

![3d_visible_change_layer](https://github.com/napari/napari/assets/16781833/7c3db27f-28cf-4e51-9fc4-c7f7aad99659)

Resolving patch:
diff --git a/napari/components/_layer_slicer.py b/napari/components/_layer_slicer.py
--- a/napari/components/_layer_slicer.py
+++ b/napari/components/_layer_slicer.py
@@ -210,9 +210,19 @@ def submit(
         # when we want to perform sync slicing anyway.
         requests: Dict[weakref.ref, _SliceRequest] = {}
         sync_layers = []
-        visible_layers = (layer for layer in layers if layer.visible)
-        for layer in visible_layers:
-            if isinstance(layer, _AsyncSliceable) and not self._force_sync:
+        for layer in layers:
+            # Slicing of non-visible layers is handled differently by sync
+            # and async slicing. For async, we do not make request since a
+            # later change to visibility triggers slicing. For sync, we want
+            # to set the slice input with `Layer._slice_dims` but don't want
+            # to fetch data yet (only if/when it becomes visible in the future).
+            # Further development should allow us to remove this special case
+            # by making the sync and async slicing code paths almost identical.
+            if (
+                isinstance(layer, _AsyncSliceable)
+                and not self._force_sync
+                and layer.visible
+            ):
                 logger.debug('Making async slice request for %s', layer)
                 request = layer._make_slice_request(dims)
                 weak_layer = weakref.ref(layer)


Test patch:
diff --git a/napari/components/_tests/test_viewer_model.py b/napari/components/_tests/test_viewer_model.py
--- a/napari/components/_tests/test_viewer_model.py
+++ b/napari/components/_tests/test_viewer_model.py
@@ -974,3 +974,18 @@ def test_slice_order_with_mixed_dims():
     assert image_2d._slice.image.view.shape == (4, 5)
     assert image_3d._slice.image.view.shape == (3, 5)
     assert image_4d._slice.image.view.shape == (2, 5)
+
+
+def test_make_layer_visible_after_slicing():
+    """See https://github.com/napari/napari/issues/6760"""
+    viewer = ViewerModel(ndisplay=2)
+    data = np.array([np.ones((2, 2)) * i for i in range(3)])
+    layer: Image = viewer.add_image(data)
+    layer.visible = False
+    assert viewer.dims.current_step[0] != 0
+    assert not np.array_equal(layer._slice.image.raw, data[0])
+
+    viewer.dims.current_step = (0, 0, 0)
+    layer.visible = True
+
+    np.testing.assert_array_equal(layer._slice.image.raw, data[0])