Repository: napari/napari

Issue:
viewer axes point in basically random directions 😂
## 🐛 Bug

I was trying to explain anisotropic image resolution to someone the other day and this napari bug severely undermined my otherwise excellent demonstration. 😂 TLDR: the axes 0, 1, 2 displayed in the viewer by default are not the same as the axes 0, 1, 2 on the input array (and by extension the positions of the `layer.scale` parameter).

## To Reproduce

```python
import numpy as np
import napari

volume = np.random.random((64, 64, 64))

viewer, layer = napari.imshow(volume, scale=(1, 2, 4), ndisplay=3)
viewer.axes.visible = True

napari.run()
```

<img width="948" alt="Screen Shot 2023-02-07 at 9 05 39 pm" src="https://user-images.githubusercontent.com/492549/217215889-c6aca123-f529-4a82-9893-4d585ac69256.png">

## Expected behavior

The code produces a 3D oblong/rectangular prism with sides of length 64, 128, and 256 in world-space. Given the scale setting above, one would expect axis 0 to point in the direction of the side of length 64, 1 for 128, and 2 for 256. Instead, you see 0 pointing along the side of length 128, 1 along the side of length 256, and 2 along the side of length 64. (**Edited:** [fixed typo](https://github.com/napari/napari/issues/5536#issuecomment-1420555774), 0->2)

## Environment

```
napari: 0.4.17rc4.dev169+g57fa998e7.d20221219
Platform: macOS-12.5.1-arm64-arm-64bit
System: MacOS 12.5.1
Python: 3.10.5 | packaged by conda-forge | (main, Jun 14 2022, 07:07:06) [Clang 13.0.1 ]
Qt: 5.15.4
PyQt5: 5.15.7
NumPy: 1.22.4
SciPy: 1.8.1
Dask: 2022.9.1
VisPy: 0.12.1
magicgui: 0.5.1
superqt: 0.3.2
in-n-out: 0.1.5
app-model: 0.1.1
npe2: 0.6.1

OpenGL:
  - GL version:  2.1 Metal - 76.3
  - MAX_TEXTURE_SIZE: 16384

Screens:
  - screen 1: resolution 1800x1169, scale 2.0

Settings path:
  - /Users/jni/Library/Application Support/napari/all_f332943dd4a538ce2ff3134fd0ff74396d8d8b23/settings.yaml
Plugins:
  - affinder: 0.2.3.dev12+g6d235f9 (2 contributions)
  - napari: 0.4.17rc4.dev169+g57fa998e7.d20221219 (77 contributions)
  - napari-console: 0.0.6 (0 contributions)
  - napari-multiscale-rendering-prototype: 0.0.1 (6 contributions)
  - napari-pymeshlab: 0.0.5 (17 contributions)
  - napari-skeleton-curator: 0.1.dev21+gf2de859 (4 contributions)
  - napari-svg: 0.1.6 (2 contributions)
  - napari-watershed: 0.0.0 (4 contributions)
  - skan: 0.11.0.dev0 (2 contributions)
  - zarpaint: 0.1.1.dev20+g61ba4a0 (14 contributions)
```

## Additional context

Semi-related: #4633


Discussion:
> Given the scale setting above, one would expect axis 0 to point in the direction of the side of length 64, 1 for 128, and 2 for 256. Instead, you see 0 pointing along the side of length 128, 1 along the side of length 256, and 0 along the side of length 64.

That should be 2 for the 64 side...
But yeah, that's pretty wierd. I double checked with (64, 128, 256) array and get the same effect: the axes are (2, 0, 1). So it's not random, but rather an off-by-one error?
Clicking the axis order button fixes it:
![image](https://user-images.githubusercontent.com/76622105/217221840-9358efed-62da-4562-a59d-6949e9871ffa.png)

> That should be 2 for the 64 side...

oops, edited the original! 🙏 

>  Clicking the axis order button fixes it:

😂 That's... weird. LOL
Seems like this isn't broken on 0.4.17, so we can git bisect to find the breaking PRs - my guess is my https://github.com/napari/napari/pull/5004. That fixes some other issues related to `order`, but may have introduced this one accidentally. If so, would be great to add at least one vispy test to cover the example above!
I'm planning to take a look at this, but others should feel too!

Resolving patch:
diff --git a/napari/_vispy/overlays/axes.py b/napari/_vispy/overlays/axes.py
--- a/napari/_vispy/overlays/axes.py
+++ b/napari/_vispy/overlays/axes.py
@@ -17,7 +17,6 @@ def __init__(self, *, viewer, overlay, parent=None) -> None:
         super().__init__(
             node=Axes(), viewer=viewer, overlay=overlay, parent=parent
         )
-        self.overlay.events.visible.connect(self._on_visible_change)
         self.overlay.events.colored.connect(self._on_data_change)
         self.overlay.events.dashed.connect(self._on_data_change)
         self.overlay.events.labels.connect(self._on_labels_visible_change)
@@ -52,6 +51,8 @@ def _on_data_change(self):
             arrows=self.overlay.arrows,
         )
 
+        self._on_labels_text_change()
+
     def _on_labels_visible_change(self):
         self.node.text.visible = self.overlay.labels
 
@@ -76,5 +77,4 @@ def reset(self):
         super().reset()
         self._on_data_change()
         self._on_labels_visible_change()
-        self._on_labels_text_change()
         self._on_zoom_change()


Test patch:
diff --git a/napari/_qt/_tests/test_qt_viewer.py b/napari/_qt/_tests/test_qt_viewer.py
--- a/napari/_qt/_tests/test_qt_viewer.py
+++ b/napari/_qt/_tests/test_qt_viewer.py
@@ -19,6 +19,7 @@
     skip_local_popups,
     skip_on_win_ci,
 )
+from napari._vispy._tests.utils import vispy_image_scene_size
 from napari._vispy.utils.gl import fix_data_dtype
 from napari.components.viewer_model import ViewerModel
 from napari.layers import Points
@@ -695,3 +696,17 @@ def test_create_non_empty_viewer_model(qtbot):
     del viewer
     qtbot.wait(50)
     gc.collect()
+
+
+def test_axes_labels(make_napari_viewer):
+    viewer = make_napari_viewer(ndisplay=3)
+    layer = viewer.add_image(np.zeros((2, 2, 2)), scale=(1, 2, 4))
+
+    layer_visual = viewer._window._qt_viewer.layer_to_visual[layer]
+    axes_visual = viewer._window._qt_viewer.overlay_to_visual[
+        viewer._overlays['axes']
+    ]
+
+    layer_visual_size = vispy_image_scene_size(layer_visual)
+    assert tuple(layer_visual_size) == (8, 4, 2)
+    assert tuple(axes_visual.node.text.text) == ('2', '1', '0')
diff --git a/napari/_vispy/_tests/test_vispy_axes_overlay.py b/napari/_vispy/_tests/test_vispy_axes_overlay.py
new file mode 100644
--- /dev/null
+++ b/napari/_vispy/_tests/test_vispy_axes_overlay.py
@@ -0,0 +1,47 @@
+from napari._vispy.overlays.axes import VispyAxesOverlay
+from napari.components import ViewerModel
+from napari.components.overlays import AxesOverlay
+
+
+def test_init_with_2d_display_of_2_dimensions():
+    axes_model = AxesOverlay()
+    viewer = ViewerModel()
+    viewer.dims.ndim = 2
+    viewer.dims.ndisplay = 3
+
+    axes_view = VispyAxesOverlay(viewer=viewer, overlay=axes_model)
+
+    assert tuple(axes_view.node.text.text) == ('1', '0')
+
+
+def test_init_with_2d_display_of_3_dimensions():
+    axes_model = AxesOverlay()
+    viewer = ViewerModel()
+    viewer.dims.ndim = 3
+    viewer.dims.ndisplay = 2
+
+    axes_view = VispyAxesOverlay(viewer=viewer, overlay=axes_model)
+
+    assert tuple(axes_view.node.text.text) == ('2', '1')
+
+
+def test_init_with_3d_display_of_2_dimensions():
+    axes_model = AxesOverlay()
+    viewer = ViewerModel()
+    viewer.dims.ndim = 2
+    viewer.dims.ndisplay = 3
+
+    axes_view = VispyAxesOverlay(viewer=viewer, overlay=axes_model)
+
+    assert tuple(axes_view.node.text.text) == ('1', '0')
+
+
+def test_init_with_3d_display_of_3_dimensions():
+    axes_model = AxesOverlay()
+    viewer = ViewerModel()
+    viewer.dims.ndim = 3
+    viewer.dims.ndisplay = 3
+
+    axes_view = VispyAxesOverlay(viewer=viewer, overlay=axes_model)
+
+    assert tuple(axes_view.node.text.text) == ('2', '1', '0')
diff --git a/napari/_vispy/_tests/test_vispy_image_layer.py b/napari/_vispy/_tests/test_vispy_image_layer.py
--- a/napari/_vispy/_tests/test_vispy_image_layer.py
+++ b/napari/_vispy/_tests/test_vispy_image_layer.py
@@ -1,36 +1,13 @@
 from itertools import permutations
-from typing import Union
 
 import numpy as np
 import pytest
-from vispy.visuals import ImageVisual, VolumeVisual
-from vispy.visuals.transforms.linear import STTransform
 
+from napari._vispy._tests.utils import vispy_image_scene_size
 from napari._vispy.layers.image import VispyImageLayer
 from napari.layers import Image
 
 
-def _node_scene_size(node: Union[ImageVisual, VolumeVisual]) -> np.ndarray:
-    """Calculates the size of a vispy image/volume node in 3D space.
-
-    The size is the shape of the node's data multiplied by the
-    node's transform scale factors.
-
-    Returns
-    -------
-    np.ndarray
-        The size of the node as a 3-vector of the form (x, y, z).
-    """
-    data = node._last_data if isinstance(node, VolumeVisual) else node._data
-    # Only use scale to ignore translate offset used to center top-left pixel.
-    transform = STTransform(scale=np.diag(node.transform.matrix))
-    # Vispy uses an xy-style ordering, whereas numpy uses a rc-style
-    # ordering, so reverse the shape before applying the transform.
-    size = transform.map(data.shape[::-1])
-    # The last element should always be one, so ignore it.
-    return size[:3]
-
-
 @pytest.mark.parametrize('order', permutations((0, 1, 2)))
 def test_3d_slice_of_2d_image_with_order(order):
     """See https://github.com/napari/napari/issues/4926
@@ -43,7 +20,7 @@ def test_3d_slice_of_2d_image_with_order(order):
 
     image._slice_dims(point=(0, 0, 0), ndisplay=3, order=order)
 
-    scene_size = _node_scene_size(vispy_image.node)
+    scene_size = vispy_image_scene_size(vispy_image)
     np.testing.assert_array_equal((4, 4, 1), scene_size)
 
 
@@ -59,7 +36,7 @@ def test_2d_slice_of_3d_image_with_order(order):
 
     image._slice_dims(point=(0, 0, 0), ndisplay=2, order=order)
 
-    scene_size = _node_scene_size(vispy_image.node)
+    scene_size = vispy_image_scene_size(vispy_image)
     np.testing.assert_array_equal((8, 8, 0), scene_size)
 
 
@@ -75,7 +52,7 @@ def test_3d_slice_of_3d_image_with_order(order):
 
     image._slice_dims(point=(0, 0, 0), ndisplay=3, order=order)
 
-    scene_size = _node_scene_size(vispy_image.node)
+    scene_size = vispy_image_scene_size(vispy_image)
     np.testing.assert_array_equal((8, 8, 8), scene_size)
 
 
@@ -91,5 +68,5 @@ def test_3d_slice_of_4d_image_with_order(order):
 
     image._slice_dims(point=(0, 0, 0, 0), ndisplay=3, order=order)
 
-    scene_size = _node_scene_size(vispy_image.node)
+    scene_size = vispy_image_scene_size(vispy_image)
     np.testing.assert_array_equal((16, 16, 16), scene_size)
diff --git a/napari/_vispy/_tests/utils.py b/napari/_vispy/_tests/utils.py
new file mode 100644
--- /dev/null
+++ b/napari/_vispy/_tests/utils.py
@@ -0,0 +1,27 @@
+import numpy as np
+from vispy.visuals import VolumeVisual
+from vispy.visuals.transforms.linear import STTransform
+
+from napari._vispy.layers.image import VispyImageLayer
+
+
+def vispy_image_scene_size(vispy_image: VispyImageLayer) -> np.ndarray:
+    """Calculates the size of a vispy image/volume in 3D space.
+
+    The size is the shape of the node's data multiplied by the
+    node's transform scale factors.
+
+    Returns
+    -------
+    np.ndarray
+        The size of the node as a 3-vector of the form (x, y, z).
+    """
+    node = vispy_image.node
+    data = node._last_data if isinstance(node, VolumeVisual) else node._data
+    # Only use scale to ignore translate offset used to center top-left pixel.
+    transform = STTransform(scale=np.diag(node.transform.matrix))
+    # Vispy uses an xy-style ordering, whereas numpy uses a rc-style
+    # ordering, so reverse the shape before applying the transform.
+    size = transform.map(data.shape[::-1])
+    # The last element should always be one, so ignore it.
+    return size[:3]