Repository: napari/napari

Issue:
[0.4.19rc4, main] IndexError when painting in a 3D labels array that has had dims rolled twice
### 🐛 Bug Report

If you have a 3D labels layer that is not a cube (z different from x/y) and roll dims twice to have dims.order (1, 2, 0) then when you paint from the top down you will get IndexError, e.g. `IndexError: index 60 is out of bounds for axis 1 with size 60` and the label will not be properly painted.

<details>

```
IndexError                                Traceback (most recent call last)
File ~/micromamba/envs/napari-dev/lib/python3.11/site-packages/superqt/utils/_throttler.py:249, in ThrottledCallable._set_future_result(self=<superqt.utils._throttler.ThrottledCallable object>)
    248 def _set_future_result(self):
--> 249     result = self._func(*self._args[: self._max_args], **self._kwargs)
        self._func = <bound method VispyCanvas._on_mouse_move of <napari._vispy.canvas.VispyCanvas object at 0x16b547f50>>
        self = <superqt.utils._throttler.ThrottledCallable object at 0x28733d3f0>
        self._args[: self._max_args] = (<MouseEvent blocked=False button=1 buttons=[1] delta=[0. 0.] handled=False is_dragging=True last_event=MouseEvent modifiers=() native=<PyQt6.QtGui.QMouseEvent object at 0x16b4a93f0> pos=[380 187] press_event=MouseEvent source=<NapariSceneCanvas (PyQt6) at 0x16b5b71d0> sources=[<NapariSceneCanvas (PyQt6) at 0x16b5b71d0>] time=1706042532.808514 type=mouse_move>,)
        self._kwargs = {}
        self._args = (<MouseEvent blocked=False button=1 buttons=[1] delta=[0. 0.] handled=False is_dragging=True last_event=MouseEvent modifiers=() native=<PyQt6.QtGui.QMouseEvent object at 0x16b4a93f0> pos=[380 187] press_event=MouseEvent source=<NapariSceneCanvas (PyQt6) at 0x16b5b71d0> sources=[<NapariSceneCanvas (PyQt6) at 0x16b5b71d0>] time=1706042532.808514 type=mouse_move>,)
        self._max_args = 1
    250     self._future.set_result(result)

File ~/Documents/dev/napari/napari/_vispy/canvas.py:447, in VispyCanvas._on_mouse_move(self=<napari._vispy.canvas.VispyCanvas object>, event=<MouseEvent blocked=False button=1 buttons=[1] d...b5b71d0>] time=1706042532.808514 type=mouse_move>)
    435 def _on_mouse_move(self, event: MouseEvent) -> None:
    436     """Called whenever mouse moves over canvas.
    437 
    438     Parameters
   (...)
    445     None
    446     """
--> 447     self._process_mouse_event(mouse_move_callbacks, event)
        event = <MouseEvent blocked=False button=1 buttons=[1] delta=[0. 0.] handled=False is_dragging=True last_event=MouseEvent modifiers=() native=<PyQt6.QtGui.QMouseEvent object at 0x16b4a93f0> pos=[380 187] press_event=MouseEvent source=<NapariSceneCanvas (PyQt6) at 0x16b5b71d0> sources=[<NapariSceneCanvas (PyQt6) at 0x16b5b71d0>] time=1706042532.808514 type=mouse_move>
        self = <napari._vispy.canvas.VispyCanvas object at 0x16b547f50>

File ~/Documents/dev/napari/napari/_vispy/canvas.py:408, in VispyCanvas._process_mouse_event(self=<napari._vispy.canvas.VispyCanvas object>, mouse_callbacks=<function mouse_move_callbacks>, event=<ReadOnlyWrapper at 0x2a5ec8600 for MouseEvent>)
    406 layer = self.viewer.layers.selection.active
    407 if layer is not None:
--> 408     mouse_callbacks(layer, event)
        event = <ReadOnlyWrapper at 0x2a5ec8600 for MouseEvent at 0x2a5ecbed0>
        layer = <Labels layer 'Labels' at 0x16b583050>
        mouse_callbacks = <function mouse_move_callbacks at 0x16695a980>

File ~/Documents/dev/napari/napari/utils/interactions.py:178, in mouse_move_callbacks(obj=<Labels layer 'Labels'>, event=<ReadOnlyWrapper at 0x2a5ec8600 for MouseEvent>)
    175 obj._persisted_mouse_event[gen].__wrapped__ = event
    176 try:
    177     # try to advance the generator
--> 178     next(gen)
        gen = <generator object draw at 0x2a5cd9f40>
    179 except StopIteration:
    180     # If done deleted the generator and stored event
    181     del obj._mouse_drag_gen[func]

File ~/Documents/dev/napari/napari/layers/labels/_labels_mouse_bindings.py:46, in draw(layer=<Labels layer 'Labels'>, event=<ReadOnlyWrapper at 0x2a5eba140 for ReadOnlyWrapper>)
     44 coordinates = mouse_event_to_labels_coordinate(layer, event)
     45 if coordinates is not None or last_cursor_coord is not None:
---> 46     layer._draw(new_label, last_cursor_coord, coordinates)
        coordinates = <class 'numpy.ndarray'> (3,) float64
        layer = <Labels layer 'Labels' at 0x16b583050>
        new_label = 1
        last_cursor_coord = <class 'numpy.ndarray'> (3,) float64
     47 last_cursor_coord = coordinates
     48 yield

File ~/Documents/dev/napari/napari/layers/labels/labels.py:1358, in Labels._draw(self=<Labels layer 'Labels'>, new_label=1, last_cursor_coord=<class 'numpy.ndarray'> (3,) float64, coordinates=<class 'numpy.ndarray'> (3,) float64)
   1356     continue
   1357 if self._mode in [Mode.PAINT, Mode.ERASE]:
-> 1358     self.paint(c, new_label, refresh=False)
        c = <class 'numpy.ndarray'> (3,) float64
        self = <Labels layer 'Labels' at 0x16b583050>
        new_label = 1
   1359 elif self._mode == Mode.FILL:
   1360     self.fill(c, new_label, refresh=False)

File ~/Documents/dev/napari/napari/layers/labels/labels.py:1398, in Labels.paint(self=<Labels layer 'Labels'>, coord=<class 'numpy.ndarray'> (3,) float64, new_label=1, refresh=False)
   1392 mask_indices = sphere_indices(radius, tuple(paint_scale))
   1394 mask_indices = mask_indices + np.round(np.array(coord_paint)).astype(
   1395     int
   1396 )
-> 1398 self._paint_indices(
        self = <Labels layer 'Labels' at 0x16b583050>
        slice_coord = [<class 'numpy.ndarray'> (97,) int64, <class 'numpy.ndarray'> (97,) int64, <class 'numpy.ndarray'> (97,) int64]
        mask_indices = <class 'numpy.ndarray'> (97, 2) int64
        shape = [60, 256]
        dims_to_paint = [0, 2]
        new_label = 1
        refresh = False
   1399     mask_indices, new_label, shape, dims_to_paint, slice_coord, refresh
   1400 )

File ~/Documents/dev/napari/napari/layers/labels/labels.py:1491, in Labels._paint_indices(self=<Labels layer 'Labels'>, mask_indices=<class 'numpy.ndarray'> (97, 2) int64, new_label=1, shape=[60, 256], dims_to_paint=[0, 2], slice_coord=(<class 'numpy.ndarray'> (97,) int64, <class 'numpy.ndarray'> (97,) int64, <class 'numpy.ndarray'> (97,) int64), refresh=False)
   1488         keep_coords = self.data[slice_coord] == self._background_label
   1489     slice_coord = tuple(sc[keep_coords] for sc in slice_coord)
-> 1491 self.data_setitem(slice_coord, new_label, refresh)
        slice_coord = (<class 'numpy.ndarray'> (97,) int64, <class 'numpy.ndarray'> (97,) int64, <class 'numpy.ndarray'> (97,) int64)
        self = <Labels layer 'Labels' at 0x16b583050>
        new_label = 1
        refresh = False

File ~/Documents/dev/napari/napari/layers/labels/labels.py:1592, in Labels.data_setitem(self=<Labels layer 'Labels'>, indices=(<class 'numpy.ndarray'> (33,) int64, <class 'numpy.ndarray'> (33,) int64, <class 'numpy.ndarray'> (33,) int64), value=1, refresh=False)
   1589     updated_slice = expand_slice(updated_slice, self.data.shape, 1)
   1590 else:
   1591     # update data view
-> 1592     self._slice.image.view[
        self = <Labels layer 'Labels' at 0x16b583050>
        self._slice.image.view = <class 'numpy.ndarray'> (256, 60) uint8
        self._slice.image = _ImageView(raw=<class 'numpy.ndarray'> (256, 60) uint8, view=<class 'numpy.ndarray'> (256, 60) uint8)
        self._slice = _ImageSliceResponse(slice_input=_SliceInput(ndisplay=2, world_slice=_ThickNDSlice(point=(29.0, 127.0, 127.0), margin_left=(0.0, 0.0, 0.0), margin_right=(0.0, 0.0, 0.0)), order=(1, 2, 0)), request_id=15, empty=False)
        displayed_indices = (<class 'numpy.ndarray'> (33,) int64, <class 'numpy.ndarray'> (33,) int64)
        visible_values = 1
   1593         displayed_indices
   1594     ] = self.colormap._data_to_texture(visible_values)
   1596 if self._updated_slice is None:
   1597     self._updated_slice = updated_slice

IndexError: index 60 is out of bounds for axis 1 with size 60
```

</details>

This bug was first reported on image.sc:
https://forum.image.sc/t/request-for-testing-napari-0-4-19-release-candidate/87972/21?u=psobolewskiphd

### 💡 Steps to Reproduce

1. open napari
2. open sample Cells3D
3. add an empty labels layer
(or open Balls 3D and select the labels layer)
4. paint a cross, left right, top bottom
5. rotate dims
6. paint cross
7. rotate dims
8. paint cross

### 💡 Expected Behavior

Painting should work as normal when having rotated dims.

### 🌎 Environment

napari: 0.5.0a2.dev519+g21e0050e
Platform: macOS-14.2.1-arm64-arm-64bit
System: MacOS 14.2.1
Python: 3.11.7 | packaged by conda-forge | (main, Dec 23 2023, 14:38:07) [Clang 16.0.6 ]
Qt: 6.6.1
PyQt6: 
NumPy: 1.26.3
SciPy: 1.11.4
Dask: 2023.12.1
VisPy: 0.14.1
magicgui: 0.8.1
superqt: 0.6.1
in-n-out: 0.1.9
app-model: 0.2.4
npe2: 0.7.3

OpenGL:
  - GL version:  2.1 Metal - 88
  - MAX_TEXTURE_SIZE: 16384

Screens:
  - screen 1: resolution 2056x1329, scale 2.0
  - screen 2: resolution 1920x1200, scale 1.0

Settings path:
  - /Users/sobolp/Library/Application Support/napari/napari-dev_36963bf0846bfe99dcf4f566a2cc780b6a723afa/settings.yaml
Plugins:
  - napari: 0.5.0a2.dev516+g4a1b0b54.d20240110 (81 contributions)
  - napari-animation: 0.0.8.dev37+g1b50156.d20240110 (2 contributions)
  - napari-console: 0.0.9 (0 contributions)
  - napari-ome-zarr: 0.5.2 (2 contributions)
  - napari-svg: 0.1.10 (2 contributions)
  - napari-threedee: 0.0.12 (26 contributions)

### 💡 Additional Context

Git bisect points to: ded3311b1fae815ef0af3572870bf50e67fdcbda


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

https://forum.image.sc/t/request-for-testing-napari-0-4-19-release-candidate/87972/22

To reproduce his bug, it is enough to double rotate and then paint. 

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
@@ -1593,7 +1593,9 @@ def data_setitem(self, indices, value, refresh=True):
         self.data[indices] = value
 
         pt_not_disp = self._get_pt_not_disp()
-        displayed_indices = index_in_slice(indices, pt_not_disp)
+        displayed_indices = index_in_slice(
+            indices, pt_not_disp, self._slice.slice_input.order
+        )
         if isinstance(value, np.ndarray):
             visible_values = value[elements_in_slice(indices, pt_not_disp)]
         else:
diff --git a/napari/utils/_indexing.py b/napari/utils/_indexing.py
--- a/napari/utils/_indexing.py
+++ b/napari/utils/_indexing.py
@@ -35,7 +35,9 @@ def elements_in_slice(
 
 
 def index_in_slice(
-    index: Tuple[npt.NDArray[np.int_], ...], position_in_axes: Dict[int, int]
+    index: Tuple[npt.NDArray[np.int_], ...],
+    position_in_axes: Dict[int, int],
+    indices_order: Tuple[int, ...],
 ) -> Tuple[npt.NDArray[np.int_], ...]:
     """Convert a NumPy fancy indexing expression from data to sliced space.
 
@@ -45,6 +47,8 @@ def index_in_slice(
         A NumPy fancy indexing expression [1]_.
     position_in_axes : dict[int, int]
         A dictionary mapping sliced (non-displayed) axes to a slice position.
+    indices_order : tuple of int
+        The order of the indices in data view.
 
     Returns
     -------
@@ -55,9 +59,9 @@ def index_in_slice(
     Examples
     --------
     >>> index = (np.arange(5), np.full(5, 1), np.arange(4, 9))
-    >>> index_in_slice(index, {0: 3})
+    >>> index_in_slice(index, {0: 3}, (0, 1, 2))
     (array([1]), array([7]))
-    >>> index_in_slice(index, {1: 1, 2: 8})
+    >>> index_in_slice(index, {1: 1, 2: 8}, (0, 1, 2))
     (array([4]),)
 
     References
@@ -66,7 +70,7 @@ def index_in_slice(
     """
     index_in_slice = elements_in_slice(index, position_in_axes)
     return tuple(
-        ix[index_in_slice]
-        for i, ix in enumerate(index)
+        index[i][index_in_slice]
+        for i in indices_order
         if i not in position_in_axes
     )


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
@@ -705,6 +705,15 @@ def test_data_setitem_multi_dim():
     )
 
 
+def test_data_setitiem_transposed_axes():
+    data = np.zeros((10, 100), dtype=np.uint32)
+    labels = Labels(data)
+    dims = Dims(ndim=2, ndisplay=2, order=(1, 0))
+    labels.data_setitem((np.array([9]), np.array([99])), 1)
+    labels._slice_dims(dims)
+    labels.data_setitem((np.array([9]), np.array([99])), 2)
+
+
 def test_selecting_label():
     """Test selecting label."""
     np.random.seed(0)
@@ -831,7 +840,7 @@ def test_paint_2d():
 
 
 def test_paint_2d_xarray():
-    """Test the memory usage of painting an xarray indirectly via timeout."""
+    """Test the memory usage of painting a xarray indirectly via timeout."""
     now = time.monotonic()
     data = xr.DataArray(np.zeros((3, 3, 1024, 1024), dtype=np.uint32))