Repository: napari/napari

Issue:
Error displaying dask array with NAPARI_ASYNC and NAPARI_OCTREE
This code block:

```python
import napari
import os
import dask.array as da

for envvar in ('NAPARI_ASYNC', 'NAPARI_OCTREE'):
    print(f'{envvar} : {os.environ.get(envvar)}')

ndim = 2
data = da.random.randint(0,255, (2048,) * ndim, chunks=(64,) * ndim, dtype='uint8')

with napari.gui_qt():
    viewer = napari.Viewer()
    viewer.add_image(data)

```

produces this traceback:
```bash
NAPARI_ASYNC : 1
NAPARI_OCTREE : 1
ERROR:root:Unhandled exception:
Traceback (most recent call last):
  File "/Users/bennettd/dev/napari/napari/_qt/qt_event_loop.py", line 213, in gui_qt
    yield app
  File "/Users/bennettd/dev/napari/examples/test.py", line 14, in <module>
    viewer.add_image(data)
  File "/Users/bennettd/dev/napari/napari/components/viewer_model.py", line 676, in add_image
    layer = image_class(data, **kwargs)
  File "/Users/bennettd/dev/napari/napari/layers/image/experimental/octree_image.py", line 75, in __init__
    super().__init__(*args, **kwargs)
  File "/Users/bennettd/dev/napari/napari/layers/image/image.py", line 276, in __init__
    self._update_dims()
  File "/Users/bennettd/dev/napari/napari/layers/base/base.py", line 530, in _update_dims
    self.refresh()
  File "/Users/bennettd/dev/napari/napari/layers/base/base.py", line 940, in refresh
    self.set_view_slice()
  File "/Users/bennettd/dev/napari/napari/layers/base/base.py", line 800, in set_view_slice
    self._set_view_slice()
  File "/Users/bennettd/dev/napari/napari/layers/image/experimental/octree_image.py", line 420, in _set_view_slice
    base_shape_2d = [base_shape[i] for i in self._dims_displayed]
  File "/Users/bennettd/dev/napari/napari/layers/image/experimental/octree_image.py", line 420, in <listcomp>
    base_shape_2d = [base_shape[i] for i in self._dims_displayed]
IndexError: tuple index out of range
```

If this errors, what kind of chunked images was the async functionality tested on?


Discussion:
Hi @d-v-b thanks for filing this! As discussed in Zulip, i'll look into this more and get back to you!!
Ah @d-v-b this errors because right now with `NAPARI_OCTREE=1` we're expecting a precomputed pyramid, sorry for missing that first time i looked at it.

Something like 

```python
ndim = 2
data = [da.random.randint(0, 255, (2**i_,) * ndim, chunks=(64,) * ndim) / 255 for i_ in range(12, 6, -1)]
``` 

would be better. I'm working on some other bug fixes in #2349 though, so you might still want to wait a little before trying this out!

Resolving patch:
diff --git a/napari/layers/image/experimental/octree.py b/napari/layers/image/experimental/octree.py
--- a/napari/layers/image/experimental/octree.py
+++ b/napari/layers/image/experimental/octree.py
@@ -67,17 +67,22 @@ def __init__(self, slice_id: int, data, meta: OctreeMetadata):
 
         LOGGER.info("Multiscale data has %d levels.", len(self.levels))
 
-        # If root level contains more than one tile, add extra levels
-        # until the root does consist of a single tile. We have to do this
-        # because we cannot draw tiles larger than the standard size right now.
-        if self.levels[-1].info.num_tiles > 1:
+        # If there is more than one level and the root level contains more
+        # than one tile, add extra levels until the root does consist of a
+        # single tile. We have to do this because we cannot draw tiles larger
+        # than the standard size right now.
+        # If there is only one level than we'll only ever be able to show tiles
+        # from that level.
+        if len(self.data) > 1 and self.levels[-1].info.num_tiles > 1:
             self.levels.extend(self._get_extra_levels())
 
         LOGGER.info("Octree now has %d total levels:", len(self.levels))
         log_levels(self.levels)
 
-        # Now the root should definitely contain only a single tile.
-        assert self.levels[-1].info.num_tiles == 1
+        # Now the root should definitely contain only a single tile if there is
+        # more than one level
+        if len(self.data) > 1:
+            assert self.levels[-1].info.num_tiles == 1
 
         # This is now the total number of levels, including the extra ones.
         self.num_levels = len(self.data)
diff --git a/napari/layers/image/experimental/octree_image.py b/napari/layers/image/experimental/octree_image.py
--- a/napari/layers/image/experimental/octree_image.py
+++ b/napari/layers/image/experimental/octree_image.py
@@ -402,6 +402,12 @@ def _set_view_slice(self) -> None:
         this class OctreeImage becomes Image. And the non-tiled multiscale
         logic in Image._set_view_slice goes away entirely.
         """
+        # Consider non-multiscale data as just having a single level
+        if self.multiscale:
+            multilevel_data = self.data
+        else:
+            multilevel_data = [self.data]
+
         if self._slice is not None:
             # For now bail out so we don't nuke an existing slice which
             # contains an existing octree. Soon we'll need to figure out
@@ -416,18 +422,21 @@ def _set_view_slice(self) -> None:
         indices = self._get_slice_indices()
 
         # TODO_OCTREE: easier way to do this?
-        base_shape = self.data[0].shape
+        base_shape = multilevel_data[0].shape
         base_shape_2d = [base_shape[i] for i in self._dims_displayed]
 
         layer_ref = LayerRef.from_layer(self)
 
         meta = OctreeMetadata(
-            layer_ref, base_shape_2d, len(self.data), self._display.tile_size
+            layer_ref,
+            base_shape_2d,
+            len(multilevel_data),
+            self._display.tile_size,
         )
 
         # OctreeSlice wants all the levels, but only the dimensions
         # of each level that we are currently viewing.
-        slice_data = [level_data[indices] for level_data in self.data]
+        slice_data = [level_data[indices] for level_data in multilevel_data]
         layer_ref = LayerRef.from_layer(self)
 
         # Create the slice, it will create the actual Octree.
diff --git a/napari/layers/image/experimental/octree_intersection.py b/napari/layers/image/experimental/octree_intersection.py
--- a/napari/layers/image/experimental/octree_intersection.py
+++ b/napari/layers/image/experimental/octree_intersection.py
@@ -8,6 +8,8 @@
 from .octree_level import OctreeLevel
 from .octree_util import OctreeDisplayOptions, spiral_index
 
+MAX_NUM_CHUNKS = 81
+
 
 class OctreeView(NamedTuple):
     """A view into the octree.
@@ -203,10 +205,17 @@ def get_chunks(self, create=False) -> List[OctreeChunk]:
         # every chunk within the view.
 
         # We use spiral indexing to get chunks from the center first
-        for row, col in spiral_index(self._row_range, self._col_range):
+        for i, (row, col) in enumerate(
+            spiral_index(self._row_range, self._col_range)
+        ):
             chunk = self.level.get_chunk(row, col, create=create)
             if chunk is not None:
                 chunks.append(chunk)
+            # We place a limit on the maximum number of chunks that
+            # we'll ever take from a level to deal with the single
+            # level tiled rendering case.
+            if i > MAX_NUM_CHUNKS:
+                break
         return chunks
 
     @property


Test patch:
diff --git a/napari/_vispy/experimental/_tests/test_vispy_tiled_image.py b/napari/_vispy/experimental/_tests/test_vispy_tiled_image.py
--- a/napari/_vispy/experimental/_tests/test_vispy_tiled_image.py
+++ b/napari/_vispy/experimental/_tests/test_vispy_tiled_image.py
@@ -177,3 +177,47 @@ def test_tiled_changing_contrast_limits(
 
     screenshot = viewer.screenshot(canvas_only=True)
     np.testing.assert_allclose(screenshot[tuple(center_coord)], target_center)
+
+
+@pytest.mark.async_only
+@pytest.mark.skip("NAPARI_OCTREE env var cannot be dynamically set")
+@skip_on_win_ci
+@skip_local_popups
+def test_tiled_single_scale(qtbot, monkeypatch, make_napari_viewer):
+    """Test rgb data works as expected."""
+    # Enable tiled rendering
+    monkeypatch.setenv("NAPARI_OCTREE", "1")
+
+    viewer = make_napari_viewer(show=True)
+    # Set canvas size to target amount
+    viewer.window.qt_viewer.view.canvas.size = (800, 600)
+
+    # Add a single scale image.
+    layer = viewer.add_image(np.ones((4000, 3000)), contrast_limits=[0, 2])
+    # zoom in so as not to load all the data
+    viewer.camera.zoom = 0.5
+
+    visual = viewer.window.qt_viewer.layer_to_visual[layer]
+
+    # Check visual is a tiled image visual
+    assert isinstance(visual, VispyTiledImageLayer)
+
+    # Wait until the chunks have added, ToDo change this to a qtbot.waitSignal
+    # Need an extra long delay here for all tiles to load, including those at
+    # edge, as zoomed in.
+    qtbot.wait(10 * LONG_LOADING_DELAY)
+
+    # Take the screenshot
+    screenshot = viewer.screenshot(canvas_only=True)
+    center_coord = np.round(np.array(screenshot.shape[:2]) / 2).astype(np.int)
+    target_center = np.array([128, 128, 128, 255], dtype='uint8')
+    screen_offset = 3  # Offset is needed as our screenshots have black borders
+
+    # Center pixel should be gray, as should edge as zoomed in
+    np.testing.assert_allclose(screenshot[tuple(center_coord)], target_center)
+    np.testing.assert_allclose(
+        screenshot[screen_offset, screen_offset], target_center
+    )
+    np.testing.assert_allclose(
+        screenshot[-screen_offset, -screen_offset], target_center
+    )