Repository: napari/napari

Issue:
Broken ndimensional points
## 🐛 Bug

due to #3381, n-dimensional points appear to be broken. Also, the dims slider seems to be broken with a singleton dimension.

## To Reproduce
With this code, you won't see the point. However, it will appear if you enable `n_dimensional`. Moreover, I would expect the dims slider to either not appear, or to show a range of (0, 0), not (-1, -1).

```python
import napari
import numpy as np


points = np.zeros(4)

v = napari.Viewer(ndisplay=3)
v.add_points(points)

napari.run()
```

## Expected behavior

The point should be visible in the zeroth slice.

## Environment

```yaml
napari: 0.4.12.dev27+g5c3c1ae7.d20210920
Platform: Linux-5.14.7-arch1-1-x86_64-with-glibc2.33
System: Arch Linux
Python: 3.9.7 (default, Aug 31 2021, 13:28:12) [GCC 11.1.0]
Qt: 5.15.2
PyQt5: 5.15.4
NumPy: 1.21.2
SciPy: 1.7.1
Dask: 2020.12.0
VisPy: 0.8.1

OpenGL:
- GL version: 4.6.0 NVIDIA 470.74
- MAX_TEXTURE_SIZE: 16384

Screens:
- screen 1: resolution 1920x1080, scale 1.0
- screen 2: resolution 1920x1200, scale 1.0

Plugins:
- animation: 0.0.2
- console: 0.0.3
- mrcfile-reader: 0.1.2
- napari-em-reader: 0.1.0
- napari-properties-plotter: 0.2.0
- ndtiffs: 0.1.1
- scikit-image: 0.4.9
- svg: 0.1.5
```

## Additional context

<!-- Add any other context about the problem here. -->



Discussion:
Ouch, I will take a look and see about adding a new test case to catch this! I think I only tested overlaying points on an image when working on #3428 and that was looking okay.

For, example adding an image layer as in the modified example below, does get the point to show up.

```Python
import napari
import numpy as np

one_pixel = np.ones((1, 1, 1, 1))
points = np.zeros(4)

v = napari.Viewer(ndisplay=3)
v.add_image(one_pixel)
v.add_points(points, size=0.1, face_color='red')

napari.run()
```

Sorry it took me awhile to get back to this. The issue is the following change to adding  `+ 0.5 * ss[i]` offsets in viewer model calls to `set_range` in #3428:
https://github.com/napari/napari/blob/315e2bf256f2e70fe96202beadeb8fb1458aca2b/napari/components/viewer_model.py#L339-L346

That change is correct for Image and Label layers, but I think we need the following instead for points:
```Python
 self.dims.set_range( 
     i, 
     ( 
         world[0, i], 
         world[1, i] + ss[i], 
         ss[i], 
     ), 
 ) 
```
where the +1 is because we changed the upper range of the Dims slider to be "exclusive" rather than "inclusive" now. I need to consider the cleanest way to implement a fix since the Viewer model deals with a LayerList that mixes these types of layers.


Resolving patch:
diff --git a/napari/components/layerlist.py b/napari/components/layerlist.py
--- a/napari/components/layerlist.py
+++ b/napari/components/layerlist.py
@@ -1,11 +1,12 @@
 import itertools
 import warnings
 from collections import namedtuple
-from typing import List, Optional
+from typing import List, Optional, Tuple
 
 import numpy as np
 
 from ..layers import Image, Labels, Layer
+from ..layers.image.image import _ImageBase
 from ..layers.utils._link_layers import get_linked_layers, layer_is_linked
 from ..utils._dtype import normalize_dtype
 from ..utils.events.containers import SelectableEventedList
@@ -123,6 +124,48 @@ def _extent_world(self) -> np.ndarray:
         """
         return self._get_extent_world([layer.extent for layer in self])
 
+    def _get_min_and_max(self, mins_list, maxes_list):
+
+        # Reverse dimensions since it is the last dimensions that are
+        # displayed.
+        mins_list = [mins[::-1] for mins in mins_list]
+        maxes_list = [maxes[::-1] for maxes in maxes_list]
+
+        with warnings.catch_warnings():
+            # Taking the nanmin and nanmax of an axis of all nan
+            # raises a warning and returns nan for that axis
+            # as we have do an explicit nan_to_num below this
+            # behaviour is acceptable and we can filter the
+            # warning
+            warnings.filterwarnings(
+                'ignore',
+                message=str(
+                    trans._('All-NaN axis encountered', deferred=True)
+                ),
+            )
+            min_v = np.nanmin(
+                list(itertools.zip_longest(*mins_list, fillvalue=np.nan)),
+                axis=1,
+            )
+            max_v = np.nanmax(
+                list(itertools.zip_longest(*maxes_list, fillvalue=np.nan)),
+                axis=1,
+            )
+
+        # 512 element default extent as documented in `_get_extent_world`
+        try:
+            min_v = np.nan_to_num(min_v, nan=-0.5)
+            max_v = np.nan_to_num(max_v, nan=511.5)
+        except TypeError:
+            # In NumPy < 1.17, nan_to_num doesn't have a nan kwarg
+            min_v = np.asarray(min_v)
+            min_v[np.isnan(min_v)] = -0.5
+            max_v = np.asarray(max_v)
+            max_v[np.isnan(max_v)] = 511.5
+
+        # switch back to original order
+        return min_v[::-1], max_v[::-1]
+
     def _get_extent_world(self, layer_extent_list):
         """Extent of layers in world coordinates.
 
@@ -134,45 +177,15 @@ def _get_extent_world(self, layer_extent_list):
         extent_world : array, shape (2, D)
         """
         if len(self) == 0:
-            min_v = [np.nan] * self.ndim
-            max_v = [np.nan] * self.ndim
+            min_v = np.asarray([-0.5] * self.ndim)
+            max_v = np.asarray([511.5] * self.ndim)
         else:
             extrema = [extent.world for extent in layer_extent_list]
-            mins = [e[0][::-1] for e in extrema]
-            maxs = [e[1][::-1] for e in extrema]
-
-            with warnings.catch_warnings():
-                # Taking the nanmin and nanmax of an axis of all nan
-                # raises a warning and returns nan for that axis
-                # as we have do an explict nan_to_num below this
-                # behaviour is acceptable and we can filter the
-                # warning
-                warnings.filterwarnings(
-                    'ignore',
-                    message=str(
-                        trans._('All-NaN axis encountered', deferred=True)
-                    ),
-                )
-                min_v = np.nanmin(
-                    list(itertools.zip_longest(*mins, fillvalue=np.nan)),
-                    axis=1,
-                )
-                max_v = np.nanmax(
-                    list(itertools.zip_longest(*maxs, fillvalue=np.nan)),
-                    axis=1,
-                )
+            mins = [e[0] for e in extrema]
+            maxs = [e[1] for e in extrema]
+            min_v, max_v = self._get_min_and_max(mins, maxs)
 
-        try:
-            min_vals = np.nan_to_num(min_v[::-1], nan=-0.5)
-            max_vals = np.nan_to_num(max_v[::-1], nan=511.5)
-        except TypeError:
-            # In NumPy < 1.17, nan_to_num doesn't have a nan kwarg
-            min_vals = np.asarray(min_v[::-1])
-            min_vals[np.isnan(min_vals)] = -0.5
-            max_vals = np.asarray(max_v[::-1])
-            max_vals[np.isnan(max_vals)] = 511.5
-
-        return np.vstack([min_vals, max_vals])
+        return np.vstack([min_v, max_v])
 
     @property
     def _step_size(self) -> np.ndarray:
@@ -188,18 +201,22 @@ def _step_size(self) -> np.ndarray:
         """
         return self._get_step_size([layer.extent for layer in self])
 
+    def _step_size_from_scales(self, scales):
+        # Reverse order so last axes of scale with different ndim are aligned
+        scales = [scale[::-1] for scale in scales]
+        full_scales = list(
+            np.array(list(itertools.zip_longest(*scales, fillvalue=np.nan)))
+        )
+        # restore original order
+        return np.nanmin(full_scales, axis=1)[::-1]
+
     def _get_step_size(self, layer_extent_list):
         if len(self) == 0:
             return np.ones(self.ndim)
         else:
-            scales = [extent.step[::-1] for extent in layer_extent_list]
-            full_scales = list(
-                np.array(
-                    list(itertools.zip_longest(*scales, fillvalue=np.nan))
-                ).T
-            )
-            min_scales = np.nanmin(full_scales, axis=0)
-            return min_scales[::-1]
+            scales = [extent.step for extent in layer_extent_list]
+            min_scales = self._step_size_from_scales(scales)
+            return min_scales
 
     @property
     def extent(self) -> Extent:
@@ -211,6 +228,58 @@ def extent(self) -> Extent:
             step=self._get_step_size(extent_list),
         )
 
+    @property
+    def _ranges(self) -> List[Tuple[float, float, float]]:
+        """Get ranges for Dims.range in world coordinates.
+
+        This shares some code in common with the `extent` property, but
+        determines Dims.range settings for each dimension such that each
+        range is aligned to pixel centers at the finest scale.
+        """
+        if len(self) == 0:
+            return [(0, 1, 1)] * self.ndim
+        else:
+            # Determine minimum step size across all layers
+            layer_extent_list = [layer.extent for layer in self]
+            scales = [extent.step for extent in layer_extent_list]
+            min_steps = self._step_size_from_scales(scales)
+
+            # Pixel-based layers need to be offset by 0.5 * min_steps to align
+            # Dims.range with pixel centers in world coordinates
+            pixel_offsets = [
+                0.5 * min_steps
+                if isinstance(layer, _ImageBase)
+                else [0] * len(min_steps)
+                for layer in self
+            ]
+
+            # Non-pixel layers need an offset of the range stop by min_steps since the upper
+            # limit of Dims.range is non-inclusive.
+            point_offsets = [
+                [0] * len(min_steps)
+                if isinstance(layer, _ImageBase)
+                else min_steps
+                for layer in self
+            ]
+
+            # Determine world coordinate extents similarly to
+            # `_get_extent_world`, but including offsets calculated above.
+            extrema = [extent.world for extent in layer_extent_list]
+            mins = [
+                e[0] + o1[: len(e[0])] for e, o1 in zip(extrema, pixel_offsets)
+            ]
+            maxs = [
+                e[1] + o1[: len(e[0])] + o2[: len(e[0])]
+                for e, o1, o2 in zip(extrema, pixel_offsets, point_offsets)
+            ]
+            min_v, max_v = self._get_min_and_max(mins, maxs)
+
+            # form range tuples, switching back to original dimension order
+            return [
+                (start, stop, step)
+                for start, stop, step in zip(min_v, max_v, min_steps)
+            ]
+
     @property
     def ndim(self) -> int:
         """Maximum dimensionality of layers.
diff --git a/napari/components/viewer_model.py b/napari/components/viewer_model.py
--- a/napari/components/viewer_model.py
+++ b/napari/components/viewer_model.py
@@ -330,20 +330,11 @@ def _on_layers_change(self, event):
             self.dims.ndim = 2
             self.dims.reset()
         else:
-            extent = self.layers.extent
-            world = extent.world
-            ss = extent.step
-            ndim = world.shape[1]
+            ranges = self.layers._ranges
+            ndim = len(ranges)
             self.dims.ndim = ndim
-            for i in range(ndim):
-                self.dims.set_range(
-                    i,
-                    (
-                        world[0, i] + 0.5 * ss[i],
-                        world[1, i] + 0.5 * ss[i],
-                        ss[i],
-                    ),
-                )
+            for i, _range in enumerate(ranges):
+                self.dims.set_range(i, _range)
 
         new_dim = self.dims.ndim
         dim_diff = new_dim - len(self.cursor.position)


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
@@ -119,6 +119,15 @@ def test_add_points():
     assert viewer.dims.ndim == 2
 
 
+def test_single_point_dims():
+    """Test dims of a Points layer with a single 3D point."""
+    viewer = ViewerModel()
+    shape = (1, 3)
+    data = np.zeros(shape)
+    viewer.add_points(data)
+    assert all(r == (0.0, 1.0, 1.0) for r in viewer.dims.range)
+
+
 def test_add_empty_points_to_empty_viewer():
     viewer = ViewerModel()
     layer = viewer.add_points(name='empty points')
diff --git a/napari/layers/points/_tests/test_points.py b/napari/layers/points/_tests/test_points.py
--- a/napari/layers/points/_tests/test_points.py
+++ b/napari/layers/points/_tests/test_points.py
@@ -246,6 +246,16 @@ def test_3D_points():
     assert len(layer.data) == 10
 
 
+def test_single_point_extent():
+    """Test extent of a single 3D point at the origin."""
+    shape = (1, 3)
+    data = np.zeros(shape)
+    layer = Points(data)
+    assert np.all(layer.extent.data == 0)
+    assert np.all(layer.extent.world == 0)
+    assert np.all(layer.extent.step == 1)
+
+
 def test_4D_points():
     """Test instantiating Points layer with random 4D data."""
     shape = (10, 4)