Repository: napari/napari

Issue:
Pyramids of length 1 lead to an exception
## 🐛 Bug

<!-- A clear and concise description of what the bug is. -->

## To Reproduce

Steps to reproduce the behavior:

* from a plugin, return a list of length 1 as a pyramid

```
  9         pyramid = []
  8         for resolution in resolutions:
  7             # data.shape is (t, c, z, y, x) by convention
  6             data = da.from_zarr(f"{self.zarr_path}{resolution}")
  5             chunk_sizes = [str(c[0]) + (" (+ %s)" % c[-1] if c[-1] != c[0] else '') for c in data.chunks]
  4             print('resolution', resolution, 'shape (t, c, z, y, x)', data.shape, 'chunks', chunk_sizes, 'dtype', data.dtype)
  3             pyramid.append(data)
  1         metadata = self.load_omero_metadata()
  2         return (pyramid, {'channel_axis': 1, **metadata})
```

<!-- If you have a code sample, error messages, stack traces, please provide it here as well -->

<details><summary>stack trace</summary>

```
/opt/ome-zarr-py/6.py in <module>
      4 with napari.gui_qt():
      5     viewer = napari.Viewer()
----> 6     viewer.open(sys.argv[1])

/opt/anaconda/envs/py36/lib/python3.6/site-packages/napari/components/add_layers_mixin.py in open(self, path, stack, plugin, layer_type, **kwargs)
    803             added.extend(
    804                 self._add_layers_with_plugins(
--> 805                     _path, kwargs, plugin=plugin, layer_type=layer_type
    806                 )
    807             )

/opt/anaconda/envs/py36/lib/python3.6/site-packages/napari/components/add_layers_mixin.py in _add_layers_with_plugins(self, path_or_paths, kwargs, plugin, layer_type)
    881                     data[1].update(valid_kwargs)
    882             # actually add the layer
--> 883             new = self._add_layer_from_data(*data)
    884             # some add_* methods return a List[Layer] others just a Layer
    885             # we want to always return a list

/opt/anaconda/envs/py36/lib/python3.6/site-packages/napari/components/add_layers_mixin.py in _add_layer_from_data(self, data, meta, layer_type)
    953
    954         try:
--> 955             layer = add_method(data, **(meta or {}))
    956         except TypeError as exc:
    957             if 'unexpected keyword argument' in str(exc):

/opt/anaconda/envs/py36/lib/python3.6/site-packages/napari/components/add_layers_mixin.py in add_image(self, data, channel_axis, rgb, colormap, contrast_limits, gamma, interpolation, rendering, iso_threshold, attenuation, name, metadata, scale, translate, opacity, blending, visible, multiscale)
    231             if multiscale is None:
    232                 multiscale = guess_multiscale(data)
--> 233             n_channels = (data[0] if multiscale else data).shape[channel_axis]
    234             kwargs['blending'] = kwargs['blending'] or 'additive'
    235

AttributeError: 'list' object has no attribute 'shape'
```
</details>

## Expected behavior

<!-- A clear and concise description of what you expected to happen. -->

Prefer to just handle the unitary pyramid as if it were unwrapped.

Workaround:

```
if len(pyramid) == 1:
    pyramid = pyramid[0]
```

## Environment

 - napari==0.3.0
 - ome-zarr==0.0.2



Discussion:
Thanks for flagging, will fix for next release

Resolving patch:
diff --git a/napari/components/add_layers_mixin.py b/napari/components/add_layers_mixin.py
--- a/napari/components/add_layers_mixin.py
+++ b/napari/components/add_layers_mixin.py
@@ -229,7 +229,7 @@ def add_image(
         else:
             # Determine if data is a multiscale
             if multiscale is None:
-                multiscale = guess_multiscale(data)
+                multiscale, data = guess_multiscale(data)
             n_channels = (data[0] if multiscale else data).shape[channel_axis]
             kwargs['blending'] = kwargs['blending'] or 'additive'
 
diff --git a/napari/layers/image/_image_utils.py b/napari/layers/image/_image_utils.py
--- a/napari/layers/image/_image_utils.py
+++ b/napari/layers/image/_image_utils.py
@@ -23,9 +23,12 @@ def guess_rgb(shape):
 
 
 def guess_multiscale(data):
-    """Guess if the passed data is multiscale of not.
+    """Guess whether the passed data is multiscale, process it accordingly.
 
-    If shape of arrays along first axis is strictly decreasing.
+    If shape of arrays along first axis is strictly decreasing, the data is
+    multiscale. If it is the same shape everywhere, it is not. Various
+    ambiguous conditions in between will result in a ValueError being raised,
+    or in an "unwrapping" of data, if data contains only one element.
 
     Parameters
     ----------
@@ -34,18 +37,43 @@ def guess_multiscale(data):
 
     Returns
     -------
-    bool
-        If data is multiscale or not.
+    multiscale : bool
+        True if the data is thought to be multiscale, False otherwise.
+    data : list or array
+        The input data, perhaps with the leading axis removed.
     """
     # If the data has ndim and is not one-dimensional then cannot be multiscale
+    # If data is a zarr array, this check ensure that subsets of it are not
+    # instantiated. (`for d in data` instantiates `d` as a NumPy array if
+    # `data` is a zarr array.)
     if hasattr(data, 'ndim') and data.ndim > 1:
-        return False
-
-    size = np.array([np.prod(d.shape, dtype=np.uint64) for d in data])
-    if len(size) > 1:
-        return bool(np.all(size[:-1] > size[1:]))
+        return False, data
+
+    shapes = [d.shape for d in data]
+    sizes = np.array([np.prod(shape, dtype=np.uint64) for shape in shapes])
+    if len(sizes) == 1 and (isinstance(data, list) or isinstance(data, tuple)):
+        # pyramid with only one level, unwrap
+        return False, data[0]
+    if len(sizes) > 1:
+        consistent = bool(np.all(sizes[:-1] > sizes[1:]))
+        flat = bool(np.all(sizes == sizes[0]))
+        if flat:
+            # note: the individual array case should be caught by the first
+            # code line in this function, hasattr(ndim) and ndim > 1.
+            raise ValueError(
+                'Input data should be an array-like object, or a sequence of '
+                'arrays of decreasing size. Got arrays of single shape: '
+                f'{shapes[0]}'
+            )
+        if not consistent:
+            raise ValueError(
+                'Input data should be an array-like object, or a sequence of '
+                'arrays of decreasing size. Got arrays in incorrect order, '
+                f'shapes: {shapes}'
+            )
+        return True, data
     else:
-        return False
+        return False, data
 
 
 def guess_labels(data):
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
@@ -155,7 +155,7 @@ def __init__(
 
         # Determine if data is a multiscale
         if multiscale is None:
-            multiscale = guess_multiscale(data)
+            multiscale, data = guess_multiscale(data)
 
         # Determine initial shape
         if multiscale:


Test patch:
diff --git a/napari/layers/image/_tests/test_image_utils.py b/napari/layers/image/_tests/test_image_utils.py
--- a/napari/layers/image/_tests/test_image_utils.py
+++ b/napari/layers/image/_tests/test_image_utils.py
@@ -29,42 +29,59 @@ def test_guess_rgb():
 
 def test_guess_multiscale():
     data = np.random.random((10, 15))
-    assert not guess_multiscale(data)
+    assert not guess_multiscale(data)[0]
 
     data = np.random.random((10, 15, 6))
-    assert not guess_multiscale(data)
+    assert not guess_multiscale(data)[0]
 
     data = [np.random.random((10, 15, 6))]
-    assert not guess_multiscale(data)
-
-    data = [np.random.random((10, 15, 6)), np.random.random((10, 15, 6))]
-    assert not guess_multiscale(data)
+    assert not guess_multiscale(data)[0]
 
     data = [np.random.random((10, 15, 6)), np.random.random((5, 7, 3))]
-    assert guess_multiscale(data)
+    assert guess_multiscale(data)[0]
 
     data = [np.random.random((10, 15, 6)), np.random.random((10, 7, 3))]
-    assert guess_multiscale(data)
+    assert guess_multiscale(data)[0]
 
     data = tuple(data)
-    assert guess_multiscale(data)
+    assert guess_multiscale(data)[0]
 
     data = tuple(
         pyramid_gaussian(np.random.random((10, 15)), multichannel=False)
     )
-    assert guess_multiscale(data)
+    assert guess_multiscale(data)[0]
 
     data = np.asarray(
         tuple(pyramid_gaussian(np.random.random((10, 15)), multichannel=False))
     )
-    assert guess_multiscale(data)
+    assert guess_multiscale(data)[0]
 
     # Check for integer overflow with big data
     s = 8192
     data = [da.ones((s,) * 3), da.ones((s // 2,) * 3), da.ones((s // 4,) * 3)]
-    assert guess_multiscale(data)
+    assert guess_multiscale(data)[0]
+
+
+def test_guess_multiscale_strip_single_scale():
+    data = [np.empty((10, 10))]
+    guess, data_out = guess_multiscale(data)
+    assert data_out is data[0]
+    assert guess is False
+
+
+def test_guess_multiscale_non_array_list():
+    """Check that non-decreasing list input raises ValueError"""
+    data = [np.empty((10, 15, 6)),] * 2  # noqa: E231
+    with pytest.raises(ValueError):
+        _, _ = guess_multiscale(data)
+
+
+def test_guess_multiscale_incorrect_order():
+    data = [np.empty((10, 15)), np.empty((5, 6)), np.empty((20, 15))]
+    with pytest.raises(ValueError):
+        _, _ = guess_multiscale(data)
 
 
 @pytest.mark.timeout(2)
 def test_timing_multiscale_big():
-    assert not guess_multiscale(data_dask)
+    assert not guess_multiscale(data_dask)[0]