Repository: napari/napari

Issue:
The viewer's status bar reports a value offset from the mouse cursor in 3D
## 🐛 Bug

The values reported in the status bar at the bottom left of the GUI do not accurately match the mouse cursor position when in 3D mode. The position whose value is reported is 1/2 pixel offset from the cursor position, so it is not very noticeable in most practical cases. The example below shows screenshots of the issue on a tiny 3D volume of shape `(2, 2, 2)`.

The expected value for the dark brown voxel at the upper left is 1 but you can see that for this cursor position near its edge the **value** reported at the bottom is 0 (the background value):
![labels_3d_pre-fix_near_edge_c2](https://user-images.githubusercontent.com/6528957/159982176-3f062484-29e2-451f-b1b5-5eb2eae4eec3.png)

If I move the cursor into the lower quadrant of the voxel, I get the expected value of 1 in the status:
![labels_3d_pre-fix-center_c2](https://user-images.githubusercontent.com/6528957/159982208-40ee7593-9d27-4245-91b6-592cb70e4bc1.png)

The 2D mode does not have the same issue. You can see that value 1 is reported here even when the cursor is very near the edge of the voxel:
![label_2d_edge_c2](https://user-images.githubusercontent.com/6528957/159982238-a47d8058-06da-4ae7-8c39-da60865489d7.png)

I already fixed this 3D behavior as part of #4303, but will split that part out into a separate small PR as the issue is unrelated to permutations.

## To Reproduce

You can run the following to get the label volume shown above and try it interactively in the viewer.

```Python
import napari
import numpy as np

v = napari.Viewer()

labels = np.arange(1, 9).reshape(2, 2, 2)
v.add_labels(labels)

napari.run()
```

<!-- If you have a code sample, error messages, stack traces, please provide it here as well -->

## Expected behavior

The reported value should always match what is underneath the mouse cursor

## Environment

current development branch of napari

## Additional context



Discussion:
There is also another, related bug that I came across while looking at this one. If the actual data array is 2D, then in the 3D mode the reported value is not correct. It only increments with vertical position of the mouse, but seems to ignore the horizontal position. I suspect that is in some way related to how dimensions are being broadcast. To see that case, you can adapt the example above to use 2D data:

```Python
import napari
import numpy as np

v = napari.Viewer()

labels = np.arange(1, 10).reshape(3, 3)
v.add_labels(labels)

napari.run()
```

(This second bug is not already addressed in #4303)

Resolving patch:
diff --git a/napari/_qt/qt_viewer.py b/napari/_qt/qt_viewer.py
--- a/napari/_qt/qt_viewer.py
+++ b/napari/_qt/qt_viewer.py
@@ -826,6 +826,11 @@ def _map_canvas2world(self, position):
         mapped_position = transform.imap(list(position))[:nd]
         position_world_slice = mapped_position[::-1]
 
+        # handle position for 3D views of 2D data
+        nd_point = len(self.viewer.dims.point)
+        if nd_point < nd:
+            position_world_slice = position_world_slice[-nd_point:]
+
         position_world = list(self.viewer.dims.point)
         for i, d in enumerate(self.viewer.dims.displayed):
             position_world[d] = position_world_slice[i]
diff --git a/napari/layers/base/base.py b/napari/layers/base/base.py
--- a/napari/layers/base/base.py
+++ b/napari/layers/base/base.py
@@ -1403,6 +1403,13 @@ def get_ray_intersections(
 
         # create the bounding box in data coordinates
         bounding_box = self._display_bounding_box(dims_displayed)
+        if not world:
+            # VisPy considers the coordinate system origin to be the canvas
+            # corner, while napari considers the origin to be the **center** of
+            # the corner pixel. To get the correct value under the mouse
+            # cursor, we need to shift the position by 0.5 pixels on each
+            # axis.
+            position = tuple(p + 0.5 for p in position)
         start_point, end_point = self._get_ray_intersections(
             position=position,
             view_direction=view_direction,
@@ -1470,7 +1477,6 @@ def _get_ray_intersections(
         front_face_normal, back_face_normal = find_front_back_face(
             click_pos_data, bounding_box, view_dir
         )
-
         if front_face_normal is None and back_face_normal is None:
             # click does not intersect the data bounding box
             return None, None
diff --git a/napari/layers/image/image.py b/napari/layers/image/image.py
--- a/napari/layers/image/image.py
+++ b/napari/layers/image/image.py
@@ -908,8 +908,15 @@ def _get_value(self, position):
         else:
             shape = raw.shape
 
-        if all(0 <= c < s for c, s in zip(coord[self._dims_displayed], shape)):
-            value = raw[tuple(coord[self._dims_displayed])]
+        if self.ndim < len(coord):
+            # handle 3D views of 2D data by omitting extra coordinate
+            offset = len(coord) - len(shape)
+            coord = coord[[d + offset for d in self._dims_displayed]]
+        else:
+            coord = coord[self._dims_displayed]
+
+        if all(0 <= c < s for c, s in zip(coord, shape)):
+            value = raw[tuple(coord)]
         else:
             value = None
 


Test patch:
diff --git a/napari/layers/_tests/test_layer_attributes.py b/napari/layers/_tests/test_layer_attributes.py
--- a/napari/layers/_tests/test_layer_attributes.py
+++ b/napari/layers/_tests/test_layer_attributes.py
@@ -2,7 +2,7 @@
 import pytest
 
 from napari._tests.utils import layer_test_data
-from napari.layers import Image
+from napari.layers import Image, Labels
 
 
 @pytest.mark.parametrize(
@@ -54,3 +54,72 @@ def test_update_data_updates_layer_extent_cache(Layer, data, ndim):
     new_extent = layer.extent
     assert old_extent is not layer.extent
     assert new_extent is layer.extent
+
+
+def _check_subpixel_values(layer, val_dict):
+    ndisplay = layer._ndisplay
+    for center, expected_value in val_dict.items():
+        # ensure all positions within the pixel extent report the same value
+        # note: values are checked in data coordinates in this function
+        for offset_0 in [-0.4999, 0, 0.4999]:
+            for offset_1 in [-0.4999, 0, 0.4999]:
+                position = [center[0] + offset_0, center[1] + offset_1]
+                view_direction = None
+                dims_displayed = None
+                if ndisplay == 3:
+                    position = [0] + position
+                    if isinstance(layer, Labels):
+                        # Labels implements _get_value_3d, Image does not
+                        view_direction = np.asarray([1.0, 0, 0])
+                        dims_displayed = [0, 1, 2]
+
+                val = layer.get_value(
+                    position=position,
+                    view_direction=view_direction,
+                    dims_displayed=dims_displayed,
+                    world=False,
+                )
+                assert val == expected_value
+
+
+@pytest.mark.parametrize('ImageClass', [Image, Labels])
+@pytest.mark.parametrize('ndim', [2, 3])
+def test_get_value_at_subpixel_offsets(ImageClass, ndim):
+    """check value at various shifts within a pixel/voxel's extent"""
+    if ndim == 3:
+        data = np.arange(1, 9).reshape(2, 2, 2)
+    elif ndim == 2:
+        data = np.arange(1, 5).reshape(2, 2)
+
+    # test using non-uniform scale per-axis
+    layer = ImageClass(data, scale=(0.5, 1, 2)[:ndim])
+    layer._slice_dims([0] * ndim, ndisplay=ndim)
+
+    # dictionary of expected values at each voxel center coordinate
+    val_dict = {
+        (0, 0): data[(0,) * (ndim - 2) + (0, 0)],
+        (0, 1): data[(0,) * (ndim - 2) + (0, 1)],
+        (1, 0): data[(0,) * (ndim - 2) + (1, 0)],
+        (1, 1): data[(0,) * (ndim - 2) + (1, 1)],
+    }
+    _check_subpixel_values(layer, val_dict)
+
+
+@pytest.mark.parametrize('ImageClass', [Image, Labels])
+def test_get_value_3d_view_of_2d_image(ImageClass):
+    """check value at various shifts within a pixel/voxel's extent"""
+    data = np.arange(1, 5).reshape(2, 2)
+
+    ndisplay = 3
+    # test using non-uniform scale per-axis
+    layer = ImageClass(data, scale=(0.5, 1))
+    layer._slice_dims([0] * ndisplay, ndisplay=ndisplay)
+
+    # dictionary of expected values at each voxel center coordinate
+    val_dict = {
+        (0, 0): data[(0, 0)],
+        (0, 1): data[(0, 1)],
+        (1, 0): data[(1, 0)],
+        (1, 1): data[(1, 1)],
+    }
+    _check_subpixel_values(layer, val_dict)