Repository: napari/napari

Issue:
Change default dims.point to the centroid of the world extents.
## 🚀 Feature
When a new layer is added, we could set `Dims.point` to the centroid of the new world extents. I think this default behavior is more likely to show an "interesting" slice.

## Motivation

<!-- Please outline the motivation for the proposal. Is your feature request related to a problem? e.g., I'm always frustrated when [...]. If this is related to another GitHub issue, please link here too -->

Currently, when a layer is added, the `Dims.point` is set to the minimum of the world extents. As @Czaki pointed out in #2104, this often leads to the default view being blank when loading data (e.g., an out of focus or empty slice in a z stack).

## Pitch

<!-- A clear and concise description of what you want to happen. -->

Update the behavior so that when a layer is added the `Dims.point` is set to the centroid of the new world extent (I can't find where that's done at the moment).

## Alternatives

<!-- A clear and concise description of any alternative solutions or features you've considered, if any. -->

## Additional context

<!-- Add any other context or screenshots about the feature request here. -->



Discussion:
This is set here:

https://github.com/napari/napari/blob/c4d1038fc3ed30dc228949cbdedf12826ec2efc2/napari/components/dims.py#L108-L111

Instead of using the 0 there should be used middle between minimum and maximum. 

Love it! Supportive
> Love it! Supportive

me too
@grlee77 can you give this a go in a new PR - then we can revisit #2104 - thanks!!
I was thinking about this more. While I think it makes sense for Points, I wonder if it will be annoying for people looking at timeseries data, you now get a middle timepoint. Maybe for Z-stack data it's ok?

Also, if we go with #2104 then if someone has points + a 3D image layer will the sliders now go out of bounds of the image to show black slices with parts of points. That might be a bit funny.

What does @jni think here? I just worry this might have some unintended consequences and be less desirable in those cases
> Also, if we go with #2104 then if someone has points + a 3D image layer will the sliders now go out of bounds of the image to show black slices with parts of points. That might be a bit funny.

Hmmm... yeah, I had not thought of this, but it does seem like that could happen for points that are more than twice the slider step size. I am not sure if excluding the point's radius from the extent is actually annoying users in practice? I noticed it caused a poor initial zoom when doing some tests on a layer with a single point, but that isn't a typical real-world use case.
> Also, if we go with #2104 then if someone has points + a 3D image layer will the sliders now go out of bounds of the image to show black slices with parts of points. That might be a bit funny.

currently, when you load data with an empty first slice you will get also an empty slice. The question is which case is more often. 

Resolving patch:
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
@@ -331,6 +331,10 @@ def _on_active_layer(self, event):
             self.cursor.size = active_layer.cursor_size
             self.camera.interactive = active_layer.interactive
 
+    @staticmethod
+    def rounded_division(min_val, max_val, precision):
+        return int(((min_val + max_val) / 2) / precision) * precision
+
     def _on_layers_change(self):
         if len(self.layers) == 0:
             self.dims.ndim = 2
@@ -340,6 +344,8 @@ def _on_layers_change(self):
             ndim = len(ranges)
             self.dims.ndim = ndim
             self.dims.set_range(range(ndim), ranges)
+            midpoint = [self.rounded_division(*_range) for _range in ranges]
+            self.dims.set_point(range(ndim), midpoint)
 
         new_dim = self.dims.ndim
         dim_diff = new_dim - len(self.cursor.position)


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
@@ -438,7 +438,7 @@ def test_process_mouse_event(make_napari_viewer):
     def on_click(layer, event):
         np.testing.assert_almost_equal(event.view_direction, [0, 1, 0, 0])
         np.testing.assert_array_equal(event.dims_displayed, [1, 2, 3])
-        assert event.dims_point[0] == 0
+        assert event.dims_point[0] == data.shape[0] // 2
 
         expected_position = view._map_canvas2world(new_pos)
         np.testing.assert_almost_equal(expected_position, list(event.position))
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
@@ -314,7 +314,7 @@ def test_swappable_dims():
     image_data = np.random.random((7, 12, 10, 15))
     image_name = viewer.add_image(image_data).name
     assert np.all(
-        viewer.layers[image_name]._data_view == image_data[0, 0, :, :]
+        viewer.layers[image_name]._data_view == image_data[3, 6, :, :]
     )
 
     points_data = np.random.randint(6, size=(10, 4))
@@ -325,18 +325,20 @@ def test_swappable_dims():
 
     labels_data = np.random.randint(20, size=(7, 12, 10, 15))
     labels_name = viewer.add_labels(labels_data).name
+    # midpoints indices into the data below depend on the data range.
+    # This depends on the values in vectors_data and thus the random seed.
     assert np.all(
-        viewer.layers[labels_name]._data_raw == labels_data[0, 0, :, :]
+        viewer.layers[labels_name]._data_raw == labels_data[4, 6, :, :]
     )
 
     # Swap dims
     viewer.dims.order = [0, 2, 1, 3]
     assert viewer.dims.order == (0, 2, 1, 3)
     assert np.all(
-        viewer.layers[image_name]._data_view == image_data[0, :, 0, :]
+        viewer.layers[image_name]._data_view == image_data[4, :, 5, :]
     )
     assert np.all(
-        viewer.layers[labels_name]._data_raw == labels_data[0, :, 0, :]
+        viewer.layers[labels_name]._data_raw == labels_data[4, :, 5, :]
     )
 
 
diff --git a/napari/components/_tests/test_world_coordinates.py b/napari/components/_tests/test_world_coordinates.py
--- a/napari/components/_tests/test_world_coordinates.py
+++ b/napari/components/_tests/test_world_coordinates.py
@@ -97,7 +97,7 @@ def test_warning_affine_slicing():
             data,
             scale=[2, 1, 1],
             translate=[10, 15, 20],
-            shear=[[1, 0, 0], [0, 1, 0], [10, 0, 1]],
+            shear=[[1, 0, 0], [0, 1, 0], [4, 0, 1]],
         )
     assert 'Non-orthogonal slicing is being requested' in str(wrn[0].message)
     with pytest.warns(UserWarning) as recorded_warnings:
diff --git a/napari/layers/_tests/test_dask_layers.py b/napari/layers/_tests/test_dask_layers.py
--- a/napari/layers/_tests/test_dask_layers.py
+++ b/napari/layers/_tests/test_dask_layers.py
@@ -119,7 +119,8 @@ def test_dask_global_optimized_slicing(delayed_dask_stack, monkeypatch):
     v = ViewerModel()
     dask_stack = delayed_dask_stack['stack']
     layer = v.add_image(dask_stack)
-    assert delayed_dask_stack['calls'] == 1  # the first stack will be loaded
+    # the first and the middle stack will be loaded
+    assert delayed_dask_stack['calls'] == 2
 
     with layer.dask_optimized_slicing() as (_, cache):
         assert cache.cache.available_bytes > 0
@@ -131,22 +132,25 @@ def test_dask_global_optimized_slicing(delayed_dask_stack, monkeypatch):
 
     # changing the Z plane should never incur calls
     # since the stack has already been loaded (& it is chunked as a 3D array)
+    current_z = v.dims.point[1]
     for i in range(3):
-        v.dims.set_point(1, i)
-        assert delayed_dask_stack['calls'] == 1  # still just the first call
+        v.dims.set_point(1, current_z + i)
+        assert delayed_dask_stack['calls'] == 2  # still just the first call
 
     # changing the timepoint will, of course, incur some compute calls
-    v.dims.set_point(0, 1)
-    assert delayed_dask_stack['calls'] == 2
-    v.dims.set_point(0, 2)
+    initial_t = v.dims.point[0]
+    v.dims.set_point(0, initial_t + 1)
     assert delayed_dask_stack['calls'] == 3
+    v.dims.set_point(0, initial_t + 2)
+    assert delayed_dask_stack['calls'] == 4
 
     # but going back to previous timepoints should not, since they are cached
-    v.dims.set_point(0, 1)
-    v.dims.set_point(0, 0)
-    assert delayed_dask_stack['calls'] == 3
-    v.dims.set_point(0, 3)
+    v.dims.set_point(0, initial_t + 1)
+    v.dims.set_point(0, initial_t + 0)
     assert delayed_dask_stack['calls'] == 4
+    # again, visiting a new point will increment the counter
+    v.dims.set_point(0, initial_t + 3)
+    assert delayed_dask_stack['calls'] == 5
 
 
 @pytest.mark.sync_only
@@ -160,7 +164,8 @@ def test_dask_unoptimized_slicing(delayed_dask_stack, monkeypatch):
     v = ViewerModel()
     dask_stack = delayed_dask_stack['stack']
     layer = v.add_image(dask_stack, cache=False)
-    assert delayed_dask_stack['calls'] == 1
+    # the first and the middle stack will be loaded
+    assert delayed_dask_stack['calls'] == 2
 
     with layer.dask_optimized_slicing() as (_, cache):
         assert cache is None
@@ -168,23 +173,25 @@ def test_dask_unoptimized_slicing(delayed_dask_stack, monkeypatch):
     # without optimized dask slicing, we get a new call to the get_array func
     # (which "re-reads" the full z stack) EVERY time we change the Z plane
     # even though we've already read this full timepoint.
+    current_z = v.dims.point[1]
     for i in range(3):
-        v.dims.set_point(1, i)
-        assert delayed_dask_stack['calls'] == 1 + i  # 😞
+        v.dims.set_point(1, current_z + i)
+        assert delayed_dask_stack['calls'] == 2 + i  # 😞
 
     # of course we still incur calls when moving to a new timepoint...
-    v.dims.set_point(0, 1)
-    v.dims.set_point(0, 2)
-    assert delayed_dask_stack['calls'] == 5
+    initial_t = v.dims.point[0]
+    v.dims.set_point(0, initial_t + 1)
+    v.dims.set_point(0, initial_t + 2)
+    assert delayed_dask_stack['calls'] == 6
 
     # without the cache we ALSO incur calls when returning to previously loaded
     # timepoints 😭
-    v.dims.set_point(0, 1)
-    v.dims.set_point(0, 0)
-    v.dims.set_point(0, 3)
+    v.dims.set_point(0, initial_t + 1)
+    v.dims.set_point(0, initial_t + 0)
+    v.dims.set_point(0, initial_t + 3)
     # all told, we have ~2x as many calls as the optimized version above.
-    # (should be exactly 8 calls, but for some reason, sometimes less on CI)
-    assert delayed_dask_stack['calls'] >= 7
+    # (should be exactly 9 calls, but for some reason, sometimes more on CI)
+    assert delayed_dask_stack['calls'] >= 9
 
 
 @pytest.mark.sync_only
@@ -203,19 +210,20 @@ def test_dask_local_unoptimized_slicing(delayed_dask_stack, monkeypatch):
     v = ViewerModel()
     dask_stack = delayed_dask_stack['stack']
     v.add_image(dask_stack, cache=False)
-    assert delayed_dask_stack['calls'] == 1
+    # the first and the middle stack will be loaded
+    assert delayed_dask_stack['calls'] == 2
 
     # without optimized dask slicing, we get a new call to the get_array func
     # (which "re-reads" the full z stack) EVERY time we change the Z plane
     # even though we've already read this full timepoint.
     for i in range(3):
         v.dims.set_point(1, i)
-        assert delayed_dask_stack['calls'] == 1 + i  # 😞
+        assert delayed_dask_stack['calls'] == 2 + 1 + i  # 😞
 
     # of course we still incur calls when moving to a new timepoint...
     v.dims.set_point(0, 1)
     v.dims.set_point(0, 2)
-    assert delayed_dask_stack['calls'] == 5
+    assert delayed_dask_stack['calls'] == 7
 
     # without the cache we ALSO incur calls when returning to previously loaded
     # timepoints 😭
@@ -224,7 +232,7 @@ def test_dask_local_unoptimized_slicing(delayed_dask_stack, monkeypatch):
     v.dims.set_point(0, 3)
     # all told, we have ~2x as many calls as the optimized version above.
     # (should be exactly 8 calls, but for some reason, sometimes less on CI)
-    assert delayed_dask_stack['calls'] >= 7
+    assert delayed_dask_stack['calls'] >= 10
 
 
 @pytest.mark.sync_only